Compare commits

..

20 Commits

Author SHA1 Message Date
uytieu
f89ab3ae55 fix: align NodeId types after merge 2026-06-30 05:02:10 -04:00
uytieu
29da036063 Merge branch 'main' into load-video-trim-node 2026-06-29 21:21:19 -04:00
steven-comfy
55c52a730a Enable cloud PostHog pageviews (#13286)
## Summary

This PR enables native PostHog `$pageview` capture for `cloud.comfy.org`
by setting cloud PostHog `capture_pageview` to `history_change`.

This keeps `autocapture` disabled, preserves the existing custom
`app:page_view` event, and lets the PostHog SDK capture the initial
pageview plus SPA history navigation pageviews. The goal is to make
cross-domain funnel tracking cleaner between `comfy.org` and
`cloud.comfy.org`, since `comfy.org` already emits native `$pageview`
events.

## Why

We want to measure the visitor funnel more accurately across:

- `comfy.org` visits
- `cloud.comfy.org` visits
- signup clicks / signup opened
- signup completion
- first cloud workflow run
- first subscription
- first credit purchase

Using native `$pageview` on both website and cloud should make PostHog
and downstream warehouse/Hex analysis cleaner for trackable users, while
leaving custom app pageview telemetry intact for existing consumers.

## Validation

- `pnpm test:unit
src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts`
- `pnpm typecheck`
- `pnpm lint:unstaged`
- pre-commit hook: `oxfmt`, `oxlint`, `eslint`, `pnpm typecheck`
- pre-push hook: `knip --cache`

Note: local validation printed an engine warning because the Codex
runtime has Node `v24.14.0` while this repo declares `>=25 <26`; the
commands above still passed.
2026-06-30 00:27:13 +00:00
Denis
fbe462143a fix: re-export GroupNodeHandler for custom node compat (#13299)
Fixes #13175

#12931 slimmed groupNode.ts down to migration-only and dropped the
export on GroupNodeHandler.

ComfyUI-Manager still imports it (import { GroupNodeConfig,
GroupNodeHandler } from "../../extensions/core/groupNode.js" in
components-manager.js), so the legacy shim no longer providing that
export throws "does not provide an export named 'GroupNodeHandler'" at
module load. That kills the whole Manager extension before setup() runs
— which is why the Manager button vanished from the toolbar since 1.47.3
(backend loads fine, frontend JS dies).

Just re-adds the export (class is still there, only the keyword was
lost) plus the existing @knipIgnoreUnusedButUsedByCustomNodes tag since
nothing in src imports it.

Tested by loading with ComfyUI-Manager installed: the groupNode.js
import error is gone and the Manager button shows again.
typecheck/knip/lint pass.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:04:59 +00:00
uytieu
4cb82edc21 Merge branch 'load-video-trim-node' of https://github.com/Comfy-Org/ComfyUI_frontend into load-video-trim-node 2026-06-29 19:53:10 -04:00
uytieu
ca11b77d85 resolve code rabbit errors 2026-06-29 19:48:19 -04:00
uytieu
d50d219fb1 Merge branch 'main' into load-video-trim-node 2026-06-29 19:08:04 -04:00
nav-tej
61cb1bcde0 fix(website): point launches Comfy MCP CTA to /mcp (#13287)
*PR Created by the Glary-Bot Agent*

---

## Summary

Update the `EXPLORE` CTA on the Comfy MCP card on
[/launches](https://comfy.org/launches) to link to
[/mcp](https://comfy.org/mcp) instead of the docs
(`docs.comfy.org/agent-tools/cloud`).

## Change

Single line in `apps/website/src/data/drops.ts`:

```diff
     cta: {
       label: EXPLORE,
-      href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
+      href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
     }
```

Matches the locale-aware pattern used by sibling cards (`/download` /
`/zh-CN/download`, `/api` / `/zh-CN/api`). Both `src/pages/mcp.astro`
and `src/pages/zh-CN/mcp.astro` already exist, so neither link is dead.
The `externalLinks.docsMcp` constant is retained because the MCP page
itself still uses it.

## Verification

- `pnpm typecheck` / `pnpm typecheck:website` clean.
- `oxfmt`, `oxlint`, `eslint` clean (all ran via lint-staged on commit).
- Manually loaded `/launches` and `/zh-CN/launches` in the dev server
and confirmed the Comfy MCP card now points to `/mcp` and `/zh-CN/mcp`
respectively.
- Loaded `/mcp` and confirmed the destination page renders ("Comfy MCP —
Drive ComfyUI from any AI agent").
- Code review by Oracle: no issues.

Screenshot shows the updated MCP card on /launches.

## Screenshots

![Comfy MCP card on /launches with EXPLORE button now linking to
/mcp](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/9f315ccc2692129159ae31efab9464684ff2f6db3e144feae6dd52fd314c0b47/pr-images/1782765166237-5e83667b-8dc3-4182-9891-609385a1dae5.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-06-29 22:29:38 +00:00
AustinMroz
9dcab4ee96 Essentials Cleanup (#13183)
Address several followup comments from #12744
2026-06-29 22:15:54 +00:00
Benjamin Lu
dc29f30b02 Track theme setting changes via telemetry (#13142)
Fix Color Palette changes not getting tracked, requested by design team.

Capture theme changes as `app:setting_changed` telemetry. The only
existing hook lived in `SettingItem.vue`, which renders *visible*
settings; `Comfy.ColorPalette` is hidden and changed through bespoke
theme UI, so it was never tracked.

Open to opinions here, we can also remove the hook in SettingItem.vue,
and just make everything that was visible opt in.

Linear:
https://linear.app/comfyorg/issue/GTM-158/track-theme-usage-with-posthog-events
2026-06-29 22:05:05 +00:00
uytieu
a2fd9cc1ed added disabled state for set and and frame buttons 2026-06-29 16:33:25 -04:00
uytieu
41ae77681d tooltip fix 2026-06-29 16:21:22 -04:00
imick-io
fb3350ee0e feat(website): redesign Comfy MCP setup steps and add button variant (#13285)
## Summary

Reworks the Comfy MCP page's **"Set up Comfy MCP in three steps"**
section to match the new design, and adds a per-action button `variant`
option to `FeatureGrid01`.

The three steps are now:

| Step | Title | Action |
| --- | --- | --- |
| 1 | Copy the MCP URL | Copy field showing
`https://cloud.comfy.org/mcp` |
| 2 | Add the connector | Filled button **"COMFY CLOUD MCP DOCS" ↗** →
MCP docs |
| 3 | Connect and sign in | Filled button **"COMFY CLOUD SKILLS" ↗** →
comfy-skills repo |

## Changes

- **`FeatureGrid01.vue`** — add `variant?: 'default' | 'outline'` to the
link card action; button now uses `card.action.variant ?? 'outline'`
instead of a hardcoded outline, so callers can opt into the filled
style.
- **`config/routes.ts`** — add `mcpSkills` external link
(`https://github.com/Comfy-Org/comfy-skills`).
- **`i18n/translations.ts`** — refresh the `mcp.setup.*` copy (en +
zh-CN): new subtitle, reworded steps, new `step2.cta` / `step3.cta`,
drop the now-unused `step1.cta`.
- **`SetupSection.vue`** — re-map cards: step 1 → copy field, steps 2 &
3 → filled link buttons.

## Test plan

- [x] `pnpm typecheck` — 0 errors
- [x] Pre-commit hooks (stylelint, oxfmt, oxlint, eslint, typecheck)
pass
- [ ] Visual check on `/mcp` and `/zh-CN/mcp` (copy field on step 1; two
filled yellow CTAs with up-right arrows on steps 2 & 3)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:08:03 +00:00
imick-io
be8e0010ee feat(website): rebuild Comfy MCP page on the design system (+ zh-CN) (#13283)
Rebuilds the **Comfy MCP** marketing page on the website design-system
stack and adds the missing zh-CN page.

## What's here
- Replaces the bespoke `components/product/mcp/` section silo with thin
`templates/mcp/*` wrappers over reusable `blocks/` + `common/`
components.
- Adds `src/pages/zh-CN/mcp.astro` and threads `locale` through every
section (was English-only).
- New/extended design-system blocks:
- `FeatureGrid01` — setup steps, with a reusable `ui/CopyableField`
(uses `@vueuse/core` `useClipboard`).
- `FeatureGrid02` — how-it-works steps with `NodeUnionIcon` connectors +
a CTA pair via `ui/button`.
- `FeatureRows01` — alternating media rows; `ReasonsSplit01` — "why"
list.
- `HeroSplit01` gained `subtitle`, a `media` slot, and a `class`
passthrough; `SectionHeader` gained `align`.
- Standardized block section spacing on `px-6 py-16 lg:py-24`.
- Refreshed all 8 MCP FAQ answers (en + zh-CN) and hydrated the FAQ
section so the accordion is interactive.

## Notes
- Stacked on the original MCP landing-page commits (previously PR
#13095); those ride along here.
- `typecheck` and `build` are green; `/mcp` and `/zh-CN/mcp` both render
in both locales.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Balpreet Brar <balpreet.brar@growthnatives.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-29 10:04:24 -07:00
imick-io
d0e97d6933 fix(website): move launches nav item and add cleanplate workflow link (#13282)
## Summary
- Move the `/launches` nav item from **Company → More** to **Products →
Features** in the main navbar
- Add the workflow link to the **Cleanplate Walkthrough** learning
tutorial (`https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/`)

## Changes
- `apps/website/src/data/mainNavigation.ts`
- `apps/website/src/data/learningTutorials.ts`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:24:10 +00:00
uytieu
648e2f2383 Probe MP4 frame rate and resource byte size
Add utilities to detect MP4 average frame-rate and HTTP resource byte size and wire them into the video filmstrip loading flow. Files added: probeVideoFrameRate.ts (+test) and httpResourceByteSize.ts (+test). useVideoFilmstrip now probes frame-rate and fetches file size, exposes fps and fileSize refs, and uses those values when computing totalFrames. Removed redundant fileSize fetching from useLoadVideoPreview and updated components/stories/tests to consume the filmstrip-provided fileSize. Also fix fps usage to read ref.value and add a small layout spacing tweak (mt-2). Tests added/updated for the new behavior.
2026-06-27 00:39:05 -04:00
uytieu
bffa754e70 set start and end frame button interaction and trim frame logic 2026-06-27 00:22:02 -04:00
uytieu
9a1e1d0785 chore: remove unused i18n key and exported type 2026-06-27 00:04:53 -04:00
uytieu
2295d78bdd Removed loadVideoTrim.noVideo keys that were unused 2026-06-27 00:03:26 -04:00
uytieu
24b25b338f update 2026-06-26 23:58:42 -04:00
200 changed files with 6213 additions and 13247 deletions

View File

@@ -35,8 +35,8 @@ jobs:
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run Vitest critical coverage gate
run: pnpm test:coverage:critical
- name: Run Vitest tests with coverage
run: pnpm test:coverage
- name: Upload unit coverage artifact
if: always() && github.event_name == 'push'

View File

@@ -67,15 +67,7 @@ jobs:
- name: Deploy report to Cloudflare
id: deploy
if: >-
${{
always() &&
!cancelled() &&
(
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork == false
)
}}
if: always() && !cancelled()
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

5
.gitignore vendored
View File

@@ -96,4 +96,7 @@ vitest.config.*.timestamp*
# Weekly docs check output
/output.txt
.amp
.amp
.vercel
.env*
!.env_example

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,3 +1,3 @@
<svg width="20" height="32" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="#F2FF59"/>
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 20 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M20 32V0C20 5.39616 15.5172 9.78053 10 9.78053C4.48276 9.78053 0 5.416 0 0V32C0 26.6038 4.48276 22.2195 10 22.2195C15.5172 22.2195 20 26.6038 20 32Z" fill="var(--fill-0, #F2FF59)"/>
</svg>

Before

Width:  |  Height:  |  Size: 279 B

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

@@ -26,7 +26,7 @@ function toggle(index: number) {
</script>
<template>
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
<!-- Left heading -->
<div

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Component } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import CopyableField from '@/components/ui/copyable-field/CopyableField.vue'
import SectionHeader from '../common/SectionHeader.vue'
type CardAction =
| {
type: 'link'
label: string
href: string
target?: '_blank'
icon?: Component
variant?: 'default' | 'outline'
}
| { type: 'code'; value: string }
export interface FeatureCard {
id: string
label?: string
title: string
description: string
action?: CardAction
}
type ColumnCount = 2 | 3 | 4
const {
cards,
columns = 3,
copiedLabel,
copyLabel,
eyebrow,
heading,
subtitle
} = defineProps<{
cards: readonly FeatureCard[]
columns?: ColumnCount
copiedLabel?: string
copyLabel?: string
eyebrow?: string
heading: string
subtitle?: string
}>()
const columnClass: Record<ColumnCount, string> = {
2: 'lg:grid-cols-2',
3: 'lg:grid-cols-3',
4: 'lg:grid-cols-4'
}
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">
{{ subtitle }}
</p>
</template>
</SectionHeader>
<div :class="cn('mt-16 grid grid-cols-1 gap-6', columnClass[columns])">
<div
v-for="card in cards"
:key="card.id"
class="bg-transparency-white-t4 flex flex-col rounded-3xl p-6 lg:p-8"
>
<p
v-if="card.label"
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ card.label }}
</p>
<h3
:class="
cn(
'text-xl font-light text-primary-comfy-canvas lg:text-2xl',
card.label && 'mt-3'
)
"
>
{{ card.title }}
</h3>
<p class="mt-3 text-sm text-smoke-700">
{{ card.description }}
</p>
<div v-if="card.action" class="mt-6">
<Button
v-if="card.action.type === 'link'"
as="a"
:href="card.action.href"
:target="card.action.target"
:rel="
card.action.target === '_blank'
? 'noopener noreferrer'
: undefined
"
:variant="card.action.variant ?? 'outline'"
:append-icon="card.action.icon"
>
{{ card.action.label }}
</Button>
<CopyableField
v-else
:value="card.action.value"
:copy-label="copyLabel"
:copied-label="copiedLabel"
/>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import SectionHeader from '../common/SectionHeader.vue'
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
type Cta = { label: string; href: string; target?: '_blank' }
export interface FeatureStep {
id: string
number: string
title: string
description: string
}
defineProps<{
heading: string
steps: readonly FeatureStep[]
primaryCta?: Cta
secondaryCta?: Cta
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader>{{ heading }}</SectionHeader>
<!-- Step cards in a row, joined by node-union connectors on desktop -->
<div
class="mt-12 flex flex-col gap-4 lg:flex-row lg:items-stretch lg:gap-0"
>
<template v-for="(step, i) in steps" :key="step.id">
<div
v-if="i > 0"
class="relative z-10 -mx-px hidden shrink-0 items-center justify-center self-stretch lg:flex"
aria-hidden="true"
>
<NodeUnionIcon
class="text-primary-comfy-yellow size-4 scale-x-150 rotate-90"
/>
</div>
<div
class="border-primary-comfy-yellow flex flex-1 flex-col rounded-[40px] border-2 bg-primary-comfy-ink p-2"
>
<div class="flex flex-1 flex-col gap-4 p-8">
<div>
<p
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
>
{{ step.number }}
</p>
<h3
class="mt-1 text-2xl font-medium tracking-widest text-primary-comfy-canvas uppercase"
>
{{ step.title }}
</h3>
</div>
<p class="text-primary-comfy-canvas">
{{ step.description }}
</p>
</div>
</div>
</template>
</div>
<div
v-if="primaryCta || secondaryCta"
class="mt-12 flex flex-col items-center gap-4 lg:flex-row lg:justify-center"
>
<Button
v-if="primaryCta"
as="a"
:href="primaryCta.href"
:target="primaryCta.target"
:rel="
primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
"
size="lg"
class="w-full lg:w-auto lg:min-w-48"
>
{{ primaryCta.label }}
</Button>
<Button
v-if="secondaryCta"
as="a"
:href="secondaryCta.href"
:target="secondaryCta.target"
:rel="
secondaryCta.target === '_blank' ? 'noopener noreferrer' : undefined
"
variant="outline"
size="lg"
class="w-full lg:w-auto lg:min-w-48"
>
{{ secondaryCta.label }}
</Button>
</div>
</section>
</template>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { Locale } from '../../i18n/translations'
import GlassCard from '../common/GlassCard.vue'
import SectionHeader from '../common/SectionHeader.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
import type { VideoTrack } from '../common/VideoPlayer.vue'
type RowMedia =
| { type: 'image'; src: string; alt?: string }
| {
type: 'video'
src: string
// <video> has no native alt; used as the player's accessible label.
alt?: string
poster?: string
tracks?: readonly VideoTrack[]
autoplay?: boolean
loop?: boolean
minimal?: boolean
hideControls?: boolean
}
export interface FeatureRow {
id: string
title: string
description: string
media: RowMedia
}
const {
heading,
eyebrow,
locale = 'en',
rows
} = defineProps<{
heading: string
eyebrow?: string
locale?: Locale
rows: readonly FeatureRow[]
}>()
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" max-width="xl">
{{ heading }}
</SectionHeader>
<div class="mt-16 flex flex-col gap-4 lg:gap-6">
<GlassCard
v-for="(row, i) in rows"
:key="row.id"
class="flex flex-col gap-8 lg:flex-row lg:items-stretch lg:gap-0"
>
<!-- Text -->
<div
:class="
cn(
'order-2 flex flex-col justify-center gap-4 p-6 lg:w-1/2 lg:p-12',
i % 2 === 0 ? 'lg:order-1' : 'lg:order-2'
)
"
>
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-3xl">
{{ row.title }}
</h3>
<p class="text-sm text-smoke-700 lg:text-base">
{{ row.description }}
</p>
</div>
<!-- Media: image or video -->
<div
:class="
cn(
'order-1 flex lg:w-1/2',
i % 2 === 0 ? 'lg:order-2' : 'lg:order-1'
)
"
>
<img
v-if="row.media.type === 'image'"
:src="row.media.src"
:alt="row.media.alt ?? row.title"
loading="lazy"
decoding="async"
class="aspect-4/3 w-full rounded-4xl object-cover"
/>
<VideoPlayer
v-else
:locale="locale"
:aria-label="row.media.alt ?? row.title"
:src="row.media.src"
:poster="row.media.poster"
:tracks="row.media.tracks"
:autoplay="row.media.autoplay"
:loop="row.media.loop"
:minimal="row.media.minimal"
:hide-controls="row.media.hideControls"
class="w-full"
/>
</div>
</GlassCard>
</div>
</section>
</template>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import type { HTMLAttributes } from 'vue'
import type { Locale } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
@@ -27,6 +29,7 @@ const {
badgeLogoAlt,
title,
titleHighlight,
subtitle,
features = [],
primaryCta,
secondaryCta,
@@ -41,14 +44,17 @@ const {
videoAutoplay = false,
videoLoop = false,
videoMinimal = false,
videoHideControls = false
videoHideControls = false,
class: className
} = defineProps<{
locale?: Locale
class?: HTMLAttributes['class']
badgeText: string
badgeLogoSrc?: string
badgeLogoAlt?: string
title: string
titleHighlight?: string
subtitle?: string
features?: string[]
primaryCta: Cta
secondaryCta?: Cta
@@ -72,7 +78,8 @@ const {
:class="
cn(
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse',
className
)
"
>
@@ -84,7 +91,7 @@ const {
/>
<h1
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] whitespace-pre-line text-primary-comfy-canvas md:text-4xl lg:text-5xl"
>
<template v-if="titleHighlight">
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
@@ -93,6 +100,13 @@ const {
<template v-else>{{ title }}</template>
</h1>
<p
v-if="subtitle"
class="mt-6 max-w-xl text-base text-primary-comfy-canvas/80"
>
{{ subtitle }}
</p>
<ul v-if="features.length" class="mt-8 space-y-3">
<li
v-for="feature in features"
@@ -127,27 +141,29 @@ const {
</div>
<div class="order-first w-full lg:order-last lg:flex-1">
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
<slot name="media">
<VideoPlayer
v-if="videoSrc"
:locale
:src="videoSrc"
:poster="videoPoster"
:tracks="videoTracks"
:autoplay="videoAutoplay"
:loop="videoLoop"
:minimal="videoMinimal"
:hide-controls="videoHideControls"
/>
<img
v-else-if="imageSrc"
:src="imageSrc"
:alt="imageAlt"
:width="imageWidth"
:height="imageHeight"
fetchpriority="high"
decoding="async"
class="aspect-4/3 w-full rounded-3xl object-cover"
/>
</slot>
</div>
</section>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
export interface Reason {
id: string
title: string
description: string
}
const { highlightClass = 'text-white' } = defineProps<{
heading: string
headingHighlight?: string
highlightClass?: string
subtitle?: string
reasons: readonly Reason[]
}>()
</script>
<template>
<section
class="max-w-9xl mx-auto flex flex-col gap-4 px-6 py-16 lg:flex-row lg:gap-16 lg:py-24"
>
<!-- Left heading -->
<div
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 lg:top-28 lg:w-115 lg:py-0"
>
<h2
class="text-4xl/16 font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl/16"
>
{{ heading
}}<span v-if="headingHighlight" :class="highlightClass">{{
headingHighlight
}}</span>
</h2>
<p v-if="subtitle" class="mt-6 text-sm text-primary-comfy-canvas/70">
{{ subtitle }}
</p>
</div>
<!-- Right reasons list -->
<div class="flex-1">
<div
v-for="reason in reasons"
:key="reason.id"
class="flex flex-col gap-4 border-b border-primary-comfy-canvas/20 py-10 first:pt-0 lg:gap-12 xl:flex-row"
>
<div class="shrink-0 xl:w-84">
<h3
class="text-2xl font-light whitespace-pre-line text-primary-comfy-canvas"
>
{{ reason.title }}
</h3>
<slot name="reason-extra" :reason="reason" />
</div>
<p class="flex-1 text-sm text-primary-comfy-canvas/70">
{{ reason.description }}
</p>
</div>
</div>
</section>
</template>

View File

@@ -7,12 +7,14 @@ const {
label,
headingTag = 'h2',
maxWidth = 'lg',
headingSize = 'section'
headingSize = 'section',
align = 'center'
} = defineProps<{
label?: string
headingTag?: 'h1' | 'h2' | 'h3'
maxWidth?: 'md' | 'lg' | 'xl'
headingSize?: 'section' | 'hero'
align?: 'center' | 'start'
}>()
const maxWidthClass = {
@@ -28,7 +30,14 @@ const headingSizeClass = {
</script>
<template>
<div :class="cn('mx-auto text-center', maxWidthClass[maxWidth])">
<div
:class="
cn(
maxWidthClass[maxWidth],
align === 'center' ? 'mx-auto text-center' : 'text-left'
)
"
>
<SectionLabel v-if="label">{{ label }}</SectionLabel>
<component
:is="headingTag"

View File

@@ -37,7 +37,8 @@ const topColumns: { title: string; links: FooterLink[] }[] = [
{ label: t('nav.comfyLocal', locale), href: routes.download },
{ label: t('nav.comfyCloud', locale), href: routes.cloud },
{ label: t('nav.comfyApi', locale), href: routes.api },
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise }
{ label: t('nav.comfyEnterprise', locale), href: routes.cloudEnterprise },
{ label: t('nav.mcpServer', locale), href: routes.mcp }
]
},
{

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
// Interactive: the copy button is inert until its host island is hydrated.
// Render under a `client:*` directive (e.g. `client:visible`) when the page
// needs it to work.
const {
value,
copyLabel = 'Copy',
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
void copy(value)
}
</script>
<template>
<div
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
{{ value }}
</span>
<button
type="button"
:aria-label="copied ? copiedLabel : copyLabel"
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />
</button>
</div>
</template>

View File

@@ -19,7 +19,8 @@ const baseRoutes = {
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
contact: '/contact',
models: '/p/supported-models'
models: '/p/supported-models',
mcp: '/mcp'
} as const
type Routes = typeof baseRoutes
@@ -65,6 +66,8 @@ export const externalLinks = {
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
mcpServer: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',
reddit: 'https://www.reddit.com/r/comfyui/',

View File

@@ -127,7 +127,7 @@ export const drops: readonly Drop[] = [
},
cta: {
label: EXPLORE,
href: { en: externalLinks.docsMcp, 'zh-CN': externalLinks.docsMcp }
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{

View File

@@ -38,7 +38,7 @@ export const learningTutorials: readonly LearningTutorial[] = [
label: 'English'
}
],
// href: '#',
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
tags: [partnerNodesTag, imageToVideoTag]
},
{

View File

@@ -69,10 +69,19 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{
header: t('nav.colFeatures', locale),
items: [
{
label: t('nav.mcpServer', locale),
href: routes.mcp,
badge: 'new'
},
// TODO: no page yet — re-enable when landing pages ship
// { label: t('nav.mcpServer', locale), href: '#', badge: 'new' },
// { label: t('nav.appMode', locale), href: '#' },
// { label: t('nav.agentSkills', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.docs', locale),
href: externalLinks.docs,
@@ -180,11 +189,6 @@ export function getMainNavigation(locale: Locale): NavItem[] {
},
// TODO: no /brand page yet
// { label: t('nav.brand', locale), href: '#' },
{
label: t('nav.launches', locale),
href: routes.launches,
badge: 'new'
},
{
label: t('nav.blogs', locale),
href: externalLinks.blog,

View File

@@ -11,6 +11,16 @@ const translations = {
'zh-CN': '图像生成视频'
},
// UI (global, reusable across sections)
'ui.copy': {
en: 'Copy',
'zh-CN': '复制'
},
'ui.copied': {
en: 'Copied',
'zh-CN': '已复制'
},
// CTAs (global, reusable across sections)
'cta.tryWorkflow': {
en: 'Try Workflow',
@@ -1825,6 +1835,311 @@ const translations = {
'我们尽力为经历面试流程的候选人提供有意义的反馈。由于申请量较大,在简历筛选阶段可能无法提供详细反馈。'
},
// MCP Meta
'mcp.meta.title': {
en: 'Comfy MCP — Drive ComfyUI from any AI agent',
'zh-CN': 'Comfy MCP — 让任何 AI 智能体驱动 ComfyUI'
},
'mcp.meta.description': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol. Generate images, video, audio, and 3D from Claude Code, Claude Desktop, and any MCP-compatible client.',
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎,可在 Claude Code、Claude Desktop 及任何兼容 MCP 的客户端中生成图像、视频、音频和 3D 内容。'
},
// MCP HeroSection
'mcp.hero.heading': {
en: 'Drive ComfyUI from\nany AI agent.',
'zh-CN': '让任何 AI 智能体\n驱动 ComfyUI。'
},
'mcp.hero.subtitle': {
en: 'Comfy MCP exposes the full ComfyUI engine over the Model Context Protocol — so your assistant can access the ecosystem, build workflows, and generate images, video, audio, or 3D.',
'zh-CN':
'Comfy MCP 通过模型上下文协议暴露完整的 ComfyUI 引擎——让你的助手能够接入生态系统、构建工作流,并生成图像、视频、音频或 3D 内容。'
},
'mcp.hero.demoPrompt': {
en: "match this frame's palette, make the hero key art",
'zh-CN': '匹配这一帧的配色,生成主视觉关键画面'
},
'mcp.hero.viewDocs': {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
},
'mcp.hero.demoGenerate': {
en: 'GENERATE',
'zh-CN': '生成'
},
'mcp.hero.demoActionGenerateImage': {
en: 'GENERATE-IMAGE',
'zh-CN': '生成图像'
},
'mcp.hero.demoActionGenerate3d': {
en: 'GENERATE-3D ASSET',
'zh-CN': '生成 3D 资产'
},
'mcp.hero.demoActionUpscale': {
en: 'UPSCALE-IMAGE',
'zh-CN': '放大图像'
},
// MCP SetupStepsSection
'mcp.setup.label': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'mcp.setup.heading': {
en: 'Set up Comfy MCP in three steps',
'zh-CN': '三步完成 Comfy MCP 配置'
},
'mcp.setup.subtitle': {
en: 'Add Comfy Cloud as a custom connector in Claude, Cursor, Codex, or any MCP-compatible client. Sign in once, and the full ComfyUI toolset is available right in your chat.',
'zh-CN':
'将 Comfy Cloud 添加为 Claude、Cursor、Codex 或任意兼容 MCP 客户端的自定义连接器。登录一次ComfyUI 全套工具即可直接在对话中使用。'
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Copy the MCP URL',
'zh-CN': '复制 MCP URL'
},
'mcp.setup.step1.description': {
en: "Click the copy button below. You'll paste it into your client in the next step.",
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Add the connector',
'zh-CN': '添加连接器'
},
'mcp.setup.step2.description': {
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
'zh-CN': 'COMFY CLOUD MCP 文档'
},
'mcp.setup.step3.label': { en: 'STEP 3', 'zh-CN': '第 3 步' },
'mcp.setup.step3.title': {
en: 'Connect and sign in',
'zh-CN': '连接并登录'
},
'mcp.setup.step3.description': {
en: 'Click Connect, sign in, and every Comfy Cloud skill is ready in your client.',
'zh-CN': '点击"连接"并登录,所有 Comfy Cloud 技能即可在你的客户端中使用。'
},
'mcp.setup.step3.cta': {
en: 'COMFY CLOUD SKILLS',
'zh-CN': 'COMFY CLOUD 技能'
},
// MCP WhyBuildSection
'mcp.why.heading': {
en: 'Why build on\n',
'zh-CN': '为什么选择\n'
},
'mcp.why.headingHighlight': {
en: 'Comfy MCP?',
'zh-CN': 'Comfy MCP'
},
'mcp.why.subtitle': {
en: 'A trusted infrastructure that lets engineers and professionals ship faster.',
'zh-CN': '一套值得信赖的基础设施,让工程师和专业人士交付更快。'
},
'mcp.why.1.title': {
en: 'Open protocol,\nany client.',
'zh-CN': '开放协议,\n任意客户端。'
},
'mcp.why.1.description': {
en: 'MCP is an open standard, so any MCP-compatible client can connect. Today Comfy supports Claude Code and Claude Desktop, with more clients coming.',
'zh-CN':
'MCP 是开放标准,因此任何兼容 MCP 的客户端都能接入。目前 Comfy 支持 Claude Code 和 Claude Desktop更多客户端即将推出。'
},
'mcp.why.2.title': {
en: 'The full engine,\nnot a sandbox.',
'zh-CN': '完整引擎,\n非沙箱环境。'
},
'mcp.why.2.description': {
en: 'Same tool your team uses. Fully connected multi-step, multi-GPU workflows. Everything available now and in the future.',
'zh-CN':
'与你团队使用的相同工具。完整连接的多步骤、多 GPU 工作流。当前及未来的所有功能均可使用。'
},
'mcp.why.3.title': {
en: 'Outputs you keep.',
'zh-CN': '输出归你所有。'
},
'mcp.why.3.description': {
en: 'Downloads go to your Comfy library — store, reuse, remix, and share without leaving the ecosystem.',
'zh-CN':
'下载内容保存到你的 Comfy 库——在生态系统内存储、复用、二次创作和分享。'
},
'mcp.why.4.title': {
en: 'Powered by\nComfy Cloud.',
'zh-CN': '由 Comfy Cloud\n提供支持。'
},
'mcp.why.4.description': {
en: 'Run without a local GPU through the same infrastructure your team already trusts.',
'zh-CN': '无需本地 GPU通过你团队信赖的相同基础设施运行。'
},
// MCP ToolsSection
'mcp.tools.heading': {
en: 'Everything ComfyUI can do,\nnow available as tools.',
'zh-CN': 'ComfyUI 能做的一切,\n现在都可作为工具调用。'
},
'mcp.tools.1.title': {
en: 'Generate anything',
'zh-CN': '生成任意内容'
},
'mcp.tools.1.description': {
en: 'Generate images, video, audio, 3D, upscale, or remove backgrounds. Add or remove elements in images, create or modify any visual, audio, or 3D asset at any scale.',
'zh-CN':
'生成图像、视频、音频、3D 内容,放大分辨率或移除背景。添加或删除图像元素,以任意规模创建或修改任何视觉、音频或 3D 资产。'
},
'mcp.tools.1.alt': {
en: 'Comfy MCP generating images, video, audio, and 3D assets from a single prompt',
'zh-CN': 'Comfy MCP 通过单个提示生成图像、视频、音频和 3D 资产'
},
'mcp.tools.2.title': {
en: 'Search the ecosystem',
'zh-CN': '搜索生态系统'
},
'mcp.tools.2.description': {
en: 'Query thousands of models, browse rankings, and choose workflow templates straight from your response.',
'zh-CN': '查询数千个模型,浏览排名,直接在对话中选择工作流模板。'
},
'mcp.tools.2.alt': {
en: 'Comfy MCP searching the ecosystem of models, rankings, and workflow templates',
'zh-CN': 'Comfy MCP 搜索模型、排名和工作流模板的生态系统'
},
'mcp.tools.3.title': {
en: 'Run real workflows',
'zh-CN': '运行真实工作流'
},
'mcp.tools.3.description': {
en: 'Turn any ComfyUI workflow into a callable tool. The full power of the engine, driven by your agent.',
'zh-CN':
'将任何 ComfyUI 工作流转换为可调用的工具。由你的智能体驱动完整的引擎能力。'
},
'mcp.tools.3.alt': {
en: 'Comfy MCP running a ComfyUI workflow as a callable tool from a chat',
'zh-CN': 'Comfy MCP 在对话中将 ComfyUI 工作流作为可调用工具运行'
},
// MCP HowItWorksSection
'mcp.howItWorks.heading': {
en: 'How it works',
'zh-CN': '工作原理'
},
'mcp.howItWorks.step1.number': { en: '01', 'zh-CN': '01' },
'mcp.howItWorks.step1.title': {
en: 'CONNECT',
'zh-CN': '连接'
},
'mcp.howItWorks.step1.description': {
en: 'Add the Comfy Cloud MCP server to Claude Code or Claude Desktop and sign in once with OAuth. No API keys to manage.',
'zh-CN':
'将 Comfy Cloud MCP 服务器添加到 Claude Code 或 Claude Desktop通过 OAuth 一次性登录。无需管理 API 密钥。'
},
'mcp.howItWorks.step2.number': { en: '02', 'zh-CN': '02' },
'mcp.howItWorks.step2.title': {
en: 'DISCOVER',
'zh-CN': '发现'
},
'mcp.howItWorks.step2.description': {
en: "Your agent gets Comfy's tools: search, generate, submit, and retrieve — everything it needs to create.",
'zh-CN':
'你的智能体获得 Comfy 的工具:搜索、生成、提交和获取——一切所需,应有尽有。'
},
'mcp.howItWorks.step3.number': { en: '03', 'zh-CN': '03' },
'mcp.howItWorks.step3.title': {
en: 'CREATE',
'zh-CN': '创作'
},
'mcp.howItWorks.step3.description': {
en: 'Request what you want, the agent queues and runs the workflow, and returns the finished result.',
'zh-CN': '描述你的需求,智能体排队执行工作流,并返回最终结果。'
},
// MCP FAQSection
'mcp.faq.heading': {
en: 'Q&As',
'zh-CN': '常见问答'
},
'mcp.faq.1.q': {
en: 'Which clients are supported?',
'zh-CN': '支持哪些客户端?'
},
'mcp.faq.1.a': {
en: 'Claude Code and Claude Desktop today, both signing in with OAuth. Support for more clients is coming.',
'zh-CN':
'目前支持 Claude Code 和 Claude Desktop均通过 OAuth 登录。更多客户端的支持即将推出。'
},
'mcp.faq.2.q': {
en: 'Do I need an API key?',
'zh-CN': '我需要 API 密钥吗?'
},
'mcp.faq.2.a': {
en: 'Not for Claude Code or Claude Desktop. They use OAuth. An API key is only needed for headless or CI setups with no browser.',
'zh-CN':
'Claude Code 和 Claude Desktop 不需要,它们使用 OAuth。仅在没有浏览器的无头或 CI 环境中才需要 API 密钥。'
},
'mcp.faq.3.q': {
en: 'Do the slash commands work in Claude Desktop?',
'zh-CN': '斜杠命令在 Claude Desktop 中可以使用吗?'
},
'mcp.faq.3.a': {
en: 'No. They ship in the Claude Code plugin. Desktop connects to the same MCP server, so the tools work; just ask in plain language.',
'zh-CN':
'不可以。斜杠命令包含在 Claude Code 插件中。Claude Desktop 连接的是同一个 MCP 服务器,因此工具可以正常使用;直接用自然语言提问即可。'
},
'mcp.faq.4.q': {
en: "The sign-in didn't open a browser.",
'zh-CN': '登录时没有打开浏览器。'
},
'mcp.faq.4.a': {
en: 'In Claude Code, run /mcp, select comfy-cloud, and choose Authenticate. In Claude Desktop, reopen the connector from Customize → Connectors.',
'zh-CN':
'在 Claude Code 中,运行 /mcp选择 comfy-cloud然后选择 Authenticate授权。在 Claude Desktop 中,从“自定义 → 连接器”重新打开该连接器。'
},
'mcp.faq.5.q': {
en: 'How do I connect in Claude Code?',
'zh-CN': '如何在 Claude Code 中连接?'
},
'mcp.faq.5.a': {
en: 'Add the marketplace and install the comfy-cloud plugin, then run /mcp → comfy-cloud → Authenticate. It adds the connection and slash commands in one step.',
'zh-CN':
'添加插件市场并安装 comfy-cloud 插件,然后运行 /mcp → comfy-cloud → Authenticate授权。一步即可添加连接和斜杠命令。'
},
'mcp.faq.6.q': {
en: "What's the server URL for Claude Desktop?",
'zh-CN': 'Claude Desktop 的服务器 URL 是什么?'
},
'mcp.faq.6.a': {
en: 'Add a custom connector in Customize → Connectors pointing to https://cloud.comfy.org/mcp, then sign in when prompted.',
'zh-CN':
'在“自定义 → 连接器”中添加一个指向 https://cloud.comfy.org/mcp 的自定义连接器,然后在提示时登录。'
},
'mcp.faq.7.q': {
en: 'What can my agent do once connected?',
'zh-CN': '连接后我的智能体能做什么?'
},
'mcp.faq.7.a': {
en: 'Generate images, video, audio, and 3D; search models, nodes, and templates; and run ComfyUI workflows, all from a chat.',
'zh-CN':
'生成图像、视频、音频和 3D搜索模型、节点和模板并运行 ComfyUI 工作流——全部在对话中完成。'
},
'mcp.faq.8.q': {
en: 'Is it generally available?',
'zh-CN': '现已正式发布了吗?'
},
'mcp.faq.8.a': {
en: 'Comfy Cloud MCP is in open beta and available to everyone.',
'zh-CN': 'Comfy Cloud MCP 目前处于公开测试阶段,所有人均可使用。'
},
// SiteNav
'nav.products': { en: 'Products', 'zh-CN': '产品' },
'nav.pricing': { en: 'Pricing', 'zh-CN': '价格' },
@@ -1867,6 +2182,7 @@ const translations = {
'nav.back': { en: 'BACK', 'zh-CN': '返回' },
'nav.badgeNew': { en: 'NEW', 'zh-CN': '新' },
// Column headers used in HeaderMainDesktop dropdowns
'nav.mcpServer': { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
'nav.colFeatures': { en: 'Features', 'zh-CN': '功能' },
'nav.colPrograms': { en: 'Programs', 'zh-CN': '项目' },
'nav.colConnect': { en: 'Connect', 'zh-CN': '联系' },

View File

@@ -0,0 +1,24 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ProductCardsSection from '../components/common/ProductCardsSection.vue'
import HeroSection from '../templates/mcp/HeroSection.vue'
import SetupSection from '../templates/mcp/SetupSection.vue'
import WhySection from '../templates/mcp/WhySection.vue'
import ToolsSection from '../templates/mcp/ToolsSection.vue'
import HowItWorksSection from '../templates/mcp/HowItWorksSection.vue'
import FAQSection from '../templates/mcp/FAQSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title={t('mcp.meta.title', 'en')}
description={t('mcp.meta.description', 'en')}
>
<HeroSection locale="en" client:load />
<SetupSection locale="en" client:visible />
<WhySection locale="en" />
<ToolsSection locale="en" />
<HowItWorksSection locale="en" />
<ProductCardsSection locale="en" label-key="products.labelProducts" />
<FAQSection client:visible locale="en" />
</BaseLayout>

View File

@@ -0,0 +1,24 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ProductCardsSection from '../../components/common/ProductCardsSection.vue'
import HeroSection from '../../templates/mcp/HeroSection.vue'
import SetupSection from '../../templates/mcp/SetupSection.vue'
import WhySection from '../../templates/mcp/WhySection.vue'
import ToolsSection from '../../templates/mcp/ToolsSection.vue'
import HowItWorksSection from '../../templates/mcp/HowItWorksSection.vue'
import FAQSection from '../../templates/mcp/FAQSection.vue'
import { t } from '../../i18n/translations'
---
<BaseLayout
title={t('mcp.meta.title', 'zh-CN')}
description={t('mcp.meta.description', 'zh-CN')}
>
<HeroSection locale="zh-CN" client:load />
<SetupSection locale="zh-CN" client:visible />
<WhySection locale="zh-CN" />
<ToolsSection locale="zh-CN" />
<HowItWorksSection locale="zh-CN" />
<ProductCardsSection locale="zh-CN" label-key="products.labelProducts" />
<FAQSection client:visible locale="zh-CN" />
</BaseLayout>

View File

@@ -162,6 +162,45 @@
animation: ripple-effect 4s linear infinite;
}
@keyframes cursor-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@utility animate-cursor-blink {
animation: cursor-blink 1s step-end infinite;
}
.card-slide-enter-active {
transition:
transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94),
opacity 0.4s ease;
}
.card-slide-enter-from {
transform: translateX(56px);
opacity: 0;
}
/* Existing cards slide down smoothly when a new card is prepended. */
.card-slide-move {
transition: transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.card-slide-leave-active {
transition: opacity 0.2s ease;
}
.card-slide-leave-to {
opacity: 0;
}
@utility animate-delay-* {
animation-delay: --value([*]);
}

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import { Check } from '@lucide/vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const PROMPT = t('mcp.hero.demoPrompt', locale)
const generateLabel = t('mcp.hero.demoGenerate', locale)
const cards = [
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'moodboard_v1.png · 6-up',
tag: 'Gmail',
thumb: '/images/mcp/mcp-thumb-moodboard.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'concepts_0103.png',
tag: 'Notion',
thumb: '/images/mcp/mcp-thumb-concepts.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerateImage',
file: 'hero_keyart.png',
tag: 'Figma',
thumb: '/images/mcp/mcp-thumb-keyart.webp'
},
{
actionKey: 'mcp.hero.demoActionGenerate3d',
file: 'asphalt_pbr/ · 5 maps',
tag: 'Blender',
thumb: '/images/mcp/mcp-thumb-asphalt.webp'
},
{
actionKey: 'mcp.hero.demoActionUpscale',
file: 'kaiju_neon_4k.png · 4096',
tag: null,
thumb: '/images/mcp/mcp-thumb-kaiju.webp'
}
] as const
const visibleCount = ref(0)
const displayedPrompt = ref('')
const promptDone = ref(false)
const displayedCards = computed(() =>
cards
.slice(0, visibleCount.value)
.map((card) => ({ ...card, action: t(card.actionKey, locale) }))
// Newest card first — it slides in right below the prompt box and pushes
// the rest down.
.reverse()
)
let timer: ReturnType<typeof setTimeout> | null = null
let active = false
function schedule(fn: () => void, ms: number) {
timer = setTimeout(() => {
if (active) fn()
}, ms)
}
function typePrompt(onDone: () => void) {
displayedPrompt.value = ''
promptDone.value = false
let i = 0
function step() {
i++
displayedPrompt.value = PROMPT.slice(0, i)
if (i < PROMPT.length) {
schedule(step, 35)
} else {
promptDone.value = true
schedule(onDone, 350)
}
}
schedule(step, 50)
}
function revealNextCard() {
if (visibleCount.value >= cards.length) {
// All done — pause then reset
schedule(() => {
visibleCount.value = 0
schedule(revealNextCard, 500)
}, 2500)
return
}
// Type the prompt, then slide in the next card
typePrompt(() => {
visibleCount.value++
schedule(revealNextCard, 400)
})
}
onMounted(() => {
active = true
schedule(revealNextCard, 600)
})
onUnmounted(() => {
active = false
if (timer) clearTimeout(timer)
})
</script>
<template>
<div class="flex flex-col gap-6 max-lg:h-[50vh]">
<!-- Prompt panel -->
<div
class="rounded-5xl flex flex-col justify-between gap-8 overflow-hidden bg-white/4 p-8"
>
<p
class="font-formula text-[17px] leading-relaxed font-light text-primary-comfy-canvas"
>
{{ displayedPrompt
}}<span
class="bg-primary-comfy-yellow ml-0.5 inline-block h-5.5 w-2 translate-y-0.5"
:class="promptDone ? 'animate-cursor-blink' : ''"
/>
</p>
<div class="flex items-center gap-3">
<div class="h-px flex-1 bg-white/10" />
<div
class="bg-primary-comfy-yellow font-formula rounded-2xl px-4 py-3 text-sm font-extrabold tracking-[0.7px] text-primary-comfy-ink uppercase"
>
{{ generateLabel }}
</div>
</div>
</div>
<!-- Cards accumulate each slides in from the right after its prompt cycle -->
<div class="relative overflow-hidden max-lg:min-h-0 max-lg:flex-1">
<TransitionGroup
name="card-slide"
tag="div"
class="flex flex-col gap-2.5 max-lg:absolute max-lg:inset-x-0 max-lg:top-0"
>
<div
v-for="(card, i) in displayedCards"
:key="card.file"
class="flex items-center gap-3.5 overflow-hidden rounded-3xl px-4 py-3.5"
:class="i === 0 ? 'bg-white/8' : 'bg-white/4'"
>
<img
:src="card.thumb"
:alt="card.action"
class="size-13.5 shrink-0 rounded-[14px] object-cover"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<p
class="font-formula text-primary-comfy-yellow text-xs font-extrabold tracking-[0.7px] uppercase"
>
{{ card.action }}
</p>
<p
class="font-formula truncate text-sm font-light text-primary-comfy-canvas"
>
{{ card.file }}
</p>
</div>
<span
v-if="card.tag"
class="font-formula relative isolate inline-flex h-8 shrink-0 items-center justify-center overflow-visible bg-transparent px-5 text-sm font-extrabold tracking-[0.7px] text-white/60 uppercase before:absolute before:inset-0 before:-z-10 before:-skew-x-12 before:rounded-sm before:bg-white/20"
>
<span class="ppformula-text-center">
{{ card.tag }}
</span>
</span>
<Check
class="size-4 shrink-0 text-primary-comfy-canvas/60"
:stroke-width="1.5"
/>
</div>
</TransitionGroup>
<!-- Bottom fade so accumulating cards dissolve into the page background -->
<div
class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-32 bg-linear-to-t from-primary-comfy-ink to-transparent lg:hidden"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const faqNumbers = [1, 2, 3, 4, 5, 6, 7, 8] as const
const faqs = faqNumbers.map((n) => ({
id: String(n),
question: t(`mcp.faq.${n}.q`, locale),
answer: t(`mcp.faq.${n}.a`, locale)
}))
</script>
<template>
<FAQSplit01 :heading="t('mcp.faq.heading', locale)" :faqs="faqs" />
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import HeroSplit01 from '../../components/blocks/HeroSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import ComfyMcpDemo from './ComfyMcpDemo.vue'
import { mcpCtas } from './ctas'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctas = mcpCtas(locale)
</script>
<template>
<HeroSplit01
:locale="locale"
class="min-h-screen"
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
>
<template #media>
<ComfyMcpDemo :locale="locale" />
</template>
</HeroSplit01>
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import FeatureGrid02 from '../../components/blocks/FeatureGrid02.vue'
import type { FeatureStep } from '../../components/blocks/FeatureGrid02.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import { mcpCtas } from './ctas'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const ctas = mcpCtas(locale)
const stepNumbers = [1, 2, 3] as const
const steps: FeatureStep[] = stepNumbers.map((n) => ({
id: String(n),
number: t(`mcp.howItWorks.step${n}.number`, locale),
title: t(`mcp.howItWorks.step${n}.title`, locale),
description: t(`mcp.howItWorks.step${n}.description`, locale)
}))
</script>
<template>
<FeatureGrid02
:heading="t('mcp.howItWorks.heading', locale)"
:steps="steps"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
/>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { ArrowUpRight } from '@lucide/vue'
import FeatureGrid01 from '../../components/blocks/FeatureGrid01.vue'
import type { FeatureCard } from '../../components/blocks/FeatureGrid01.vue'
import { externalLinks } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const cards: FeatureCard[] = [
{
id: 'step1',
label: t('mcp.setup.step1.label', locale),
title: t('mcp.setup.step1.title', locale),
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: externalLinks.mcpServer
}
},
{
id: 'step2',
label: t('mcp.setup.step2.label', locale),
title: t('mcp.setup.step2.title', locale),
description: t('mcp.setup.step2.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step2.cta', locale),
href: externalLinks.docsMcp,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
},
{
id: 'step3',
label: t('mcp.setup.step3.label', locale),
title: t('mcp.setup.step3.title', locale),
description: t('mcp.setup.step3.description', locale),
action: {
type: 'link',
label: t('mcp.setup.step3.cta', locale),
href: externalLinks.mcpSkills,
target: '_blank',
icon: ArrowUpRight,
variant: 'default'
}
}
]
</script>
<template>
<FeatureGrid01
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"
:columns="3"
:cards="cards"
:copy-label="t('ui.copy', locale)"
:copied-label="t('ui.copied', locale)"
/>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import FeatureRows01 from '../../components/blocks/FeatureRows01.vue'
import type { FeatureRow } from '../../components/blocks/FeatureRows01.vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
type ToolMedia =
| { type: 'image'; src: string }
| {
type: 'video'
src: string
autoplay?: boolean
loop?: boolean
hideControls?: boolean
}
const tools: { n: 1 | 2 | 3; media: ToolMedia; altKey?: TranslationKey }[] = [
{
n: 1,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/generate-everything.gif'
},
altKey: 'mcp.tools.1.alt'
},
{
n: 2,
media: {
type: 'image',
src: 'https://media.comfy.org/website/mcp/search-ecosystem.png'
},
altKey: 'mcp.tools.2.alt'
},
{
n: 3,
media: {
type: 'video',
src: 'https://media.comfy.org/website/mcp/run-real-workflows.mp4',
autoplay: true,
loop: true,
hideControls: true
},
altKey: 'mcp.tools.3.alt'
}
]
const rows: FeatureRow[] = tools.map(({ n, media, altKey }) => {
const alt = altKey ? t(altKey, locale) : undefined
return {
id: String(n),
title: t(`mcp.tools.${n}.title`, locale),
description: t(`mcp.tools.${n}.description`, locale),
media: { ...media, alt }
}
})
</script>
<template>
<FeatureRows01
:locale="locale"
:heading="t('mcp.tools.heading', locale)"
:rows="rows"
/>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import ReasonsSplit01 from '../../components/blocks/ReasonsSplit01.vue'
import type { Reason } from '../../components/blocks/ReasonsSplit01.vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const reasonNumbers = [1, 2, 3, 4] as const
const reasons: Reason[] = reasonNumbers.map((n) => ({
id: String(n),
title: t(`mcp.why.${n}.title`, locale),
description: t(`mcp.why.${n}.description`, locale)
}))
</script>
<template>
<ReasonsSplit01
:heading="t('mcp.why.heading', locale)"
:heading-highlight="t('mcp.why.headingHighlight', locale)"
highlight-class="text-primary-comfy-yellow"
:subtitle="t('mcp.why.subtitle', locale)"
:reasons="reasons"
/>
</template>

View File

@@ -0,0 +1,27 @@
import { externalLinks, getRoutes } from '../../config/routes'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
export interface McpCta {
label: string
href: string
target?: '_blank'
}
/**
* The two calls-to-action shared by the MCP hero and "how it works" sections:
* view the docs, or run a workflow in the cloud.
*/
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
return {
docs: {
label: t('mcp.hero.viewDocs', locale),
href: externalLinks.docsMcp,
target: '_blank'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud
}
}
}

View File

@@ -14,44 +14,36 @@ const SHARE_AUTH_STORAGE_KEY = 'Comfy.PreservedQuery.share_auth'
* routes and elements.
*/
test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
test(
'cloud build redirects unauthenticated users to login',
{ tag: '@critical' },
async ({ page }) => {
await page.goto(APP_URL)
// Cloud build has an auth guard that redirects to /cloud/login.
// This route only exists in the cloud distribution — it's tree-shaken
// in the OSS build. Its presence confirms the cloud build is active.
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
}
)
test('cloud build redirects unauthenticated users to login', async ({
page
}) => {
await page.goto(APP_URL)
// Cloud build has an auth guard that redirects to /cloud/login.
// This route only exists in the cloud distribution — it's tree-shaken
// in the OSS build. Its presence confirms the cloud build is active.
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
})
test(
'preserves share auth attribution before redirecting logged-out users',
{ tag: '@critical' },
async ({ page }) => {
await page.goto(new URL('/?share=abc', APP_URL).toString())
test('preserves share auth attribution before redirecting logged-out users', async ({
page
}) => {
await page.goto(new URL('/?share=abc', APP_URL).toString())
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
await expect
.poll(() =>
page.evaluate(
(key) => sessionStorage.getItem(key),
SHARE_AUTH_STORAGE_KEY
)
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
await expect
.poll(() =>
page.evaluate(
(key) => sessionStorage.getItem(key),
SHARE_AUTH_STORAGE_KEY
)
.toBe(JSON.stringify({ share: 'abc' }))
}
)
)
.toBe(JSON.stringify({ share: 'abc' }))
})
test(
'cloud login page renders sign-in options',
{ tag: '@critical' },
async ({ page }) => {
await page.goto(APP_URL)
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
// Verify cloud-specific login UI is rendered
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
}
)
test('cloud login page renders sign-in options', async ({ page }) => {
await page.goto(APP_URL)
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
// Verify cloud-specific login UI is rendered
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
})
})

View File

@@ -97,38 +97,34 @@ test.describe(
'Execute to selected output nodes',
{ tag: ['@smoke', '@workflow'] },
() => {
test(
'Execute to selected output nodes',
{ tag: '@critical' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
const input = await comfyPage.nodeOps.getNodeRefById(3)
const output1 = await comfyPage.nodeOps.getNodeRefById(1)
const output2 = await comfyPage.nodeOps.getNodeRefById(4)
await expect
.poll(async () => (await input.getWidget(0)).getValue())
.toBe('foo')
await expect
.poll(async () => (await output1.getWidget(0)).getValue())
.toBe('')
await expect
.poll(async () => (await output2.getWidget(0)).getValue())
.toBe('')
test('Execute to selected output nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
const input = await comfyPage.nodeOps.getNodeRefById(3)
const output1 = await comfyPage.nodeOps.getNodeRefById(1)
const output2 = await comfyPage.nodeOps.getNodeRefById(4)
await expect
.poll(async () => (await input.getWidget(0)).getValue())
.toBe('foo')
await expect
.poll(async () => (await output1.getWidget(0)).getValue())
.toBe('')
await expect
.poll(async () => (await output2.getWidget(0)).getValue())
.toBe('')
await output1.click('title')
await output1.click('title')
await comfyPage.command.executeCommand('Comfy.QueueSelectedOutputNodes')
await expect
.poll(async () => (await input.getWidget(0)).getValue())
.toBe('foo')
await expect
.poll(async () => (await output1.getWidget(0)).getValue())
.toBe('foo')
await expect
.poll(async () => (await output2.getWidget(0)).getValue())
.toBe('')
}
)
await comfyPage.command.executeCommand('Comfy.QueueSelectedOutputNodes')
await expect
.poll(async () => (await input.getWidget(0)).getValue())
.toBe('foo')
await expect
.poll(async () => (await output1.getWidget(0)).getValue())
.toBe('foo')
await expect
.poll(async () => (await output2.getWidget(0)).getValue())
.toBe('')
})
}
)

View File

@@ -13,37 +13,33 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
type TestSettingId = keyof Settings
test.describe('Topbar commands', () => {
test(
'Should allow registering topbar commands',
{ tag: '@critical' },
async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'foo',
label: 'foo-command',
function: () => {
window.foo = true
}
test('Should allow registering topbar commands', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'foo',
label: 'foo-command',
function: () => {
window.foo = true
}
],
menuCommands: [
{
path: ['ext'],
commands: ['foo']
}
]
})
}
],
menuCommands: [
{
path: ['ext'],
commands: ['foo']
}
]
})
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
await expect
.poll(() => comfyPage.page.evaluate(() => window.foo))
.toBe(true)
}
)
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
await expect
.poll(() => comfyPage.page.evaluate(() => window.foo))
.toBe(true)
})
test('Should not allow register command defined in other extension', async ({
comfyPage

View File

@@ -22,15 +22,11 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
.toBe(1)
})
test(
'Validate workflow links',
{ tag: '@critical' },
async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Validation.Workflows', true)
await comfyPage.workflow.loadWorkflow('links/bad_link')
await expect(comfyPage.toast.visibleToasts).toHaveCount(2)
}
)
test('Validate workflow links', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Validation.Workflows', true)
await comfyPage.workflow.loadWorkflow('links/bad_link')
await expect(comfyPage.toast.visibleToasts).toHaveCount(2)
})
// Regression: duplicate links with shifted target_slot (widget-to-input
// conversion) caused the wrong link to survive during deduplication.

View File

@@ -8,24 +8,20 @@ test.describe('Node search box V2', { tag: '@node' }, () => {
await comfyPage.searchBoxV2.setup()
})
test(
'Can open search and add node',
{ tag: '@critical' },
async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
test('Can open search and add node', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await searchBoxV2.open()
await searchBoxV2.input.fill('KSampler')
await expect(searchBoxV2.results.first()).toBeVisible()
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
}
)
await comfyPage.page.keyboard.press('Enter')
await expect(searchBoxV2.input).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBe(initialCount + 1)
})
test('Can add first default result with Enter', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage

View File

@@ -33,21 +33,19 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
await cleanupFakeModel(comfyPage)
})
test(
'Should show missing models group in errors tab',
{ tag: '@critical' },
async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
test('Should show missing models group in errors tab', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelsGroup).toBeVisible()
await expect(
missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
}
)
const missingModelsGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelsGroup).toBeVisible()
await expect(
missingModelsGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should display model name and metadata', async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')

View File

@@ -12,25 +12,23 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
)
})
test(
'Should show missing node pack card with guidance',
{ tag: '@critical' },
async ({ comfyPage }) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
test('Should show missing node pack card with guidance', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
const missingNodeGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodePacksGroup
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
await expect(missingNodeGroup).toBeVisible()
await expect(
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
}
)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
).toBeVisible()
await expect(missingNodeGroup).toBeVisible()
await expect(
missingNodeGroup.getByTestId(TestIds.dialogs.errorGroupDisplayMessage)
).toHaveText(/\S/)
})
test('Should show unknown pack node rows by default', async ({
comfyPage

View File

@@ -54,19 +54,13 @@ test.describe('Queue overlay', () => {
await comfyPage.setup()
})
test(
'Toggle button opens expanded queue overlay',
{ tag: '@critical' },
async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
await toggle.click()
// Expanded overlay should show job items
await expect(
comfyPage.page.locator('[data-job-id]').first()
).toBeVisible()
}
)
// Expanded overlay should show job items
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeVisible()
})
test('Overlay shows filter tabs (All, Completed)', async ({ comfyPage }) => {
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)

View File

@@ -129,7 +129,7 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test(
'Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip',
{ tag: ['@vue-nodes', '@critical'] },
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-link-and-proxied-primitive'

View File

@@ -51,11 +51,8 @@
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
"test:browser": "pnpm exec playwright test",
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:critical": "pnpm exec playwright test --project=chromium --grep @critical",
"test:browser:cloud-critical": "pnpm exec playwright test --project=cloud --grep @critical",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
"test:unit": "vitest run",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

View File

@@ -266,6 +266,9 @@
--component-node-widget-promoted: var(--color-purple-700);
--component-node-widget-advanced: var(--color-azure-400);
--video-trim-selection-background: var(--color-datatype-CLIP, #ffd500);
--video-trim-playhead-background: #f0513b;
/* Default UI element color palette variables */
--palette-contrast-mix-color: #fff;
--palette-interface-panel-surface: var(--comfy-menu-bg);
@@ -549,6 +552,10 @@
);
--color-component-node-widget-promoted: var(--component-node-widget-promoted);
--color-component-node-widget-advanced: var(--component-node-widget-advanced);
--color-video-trim-selection-background: var(
--video-trim-selection-background
);
--color-video-trim-playhead-background: var(--video-trim-playhead-background);
/* Semantic tokens */
--color-base-foreground: var(--base-foreground);

View File

@@ -1,75 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('runWhenGlobalIdle', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('falls back to a timeout when idle callbacks are unavailable', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestIdleCallback', undefined)
vi.stubGlobal('cancelIdleCallback', undefined)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
const disposable = runWhenGlobalIdle(runner)
await vi.runAllTimersAsync()
expect(runner).toHaveBeenCalledOnce()
const deadline = runner.mock.calls[0][0]
expect(deadline.didTimeout).toBe(true)
expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0)
disposable.dispose()
disposable.dispose()
})
it('cancels fallback idle work before it runs', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestIdleCallback', undefined)
vi.stubGlobal('cancelIdleCallback', undefined)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
runWhenGlobalIdle(runner).dispose()
await vi.runAllTimersAsync()
expect(runner).not.toHaveBeenCalled()
})
it('uses native idle callbacks when available', async () => {
const requestIdleCallback = vi.fn(() => 42)
const cancelIdleCallback = vi.fn()
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
const disposable = runWhenGlobalIdle(runner, 250)
expect(requestIdleCallback).toHaveBeenCalledWith(runner, { timeout: 250 })
disposable.dispose()
disposable.dispose()
expect(cancelIdleCallback).toHaveBeenCalledOnce()
expect(cancelIdleCallback).toHaveBeenCalledWith(42)
})
it('omits native idle timeout options when no timeout is supplied', async () => {
const requestIdleCallback = vi.fn(() => 7)
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
vi.stubGlobal('cancelIdleCallback', vi.fn())
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
runWhenGlobalIdle(runner)
expect(requestIdleCallback).toHaveBeenCalledWith(runner, undefined)
})
})

View File

@@ -4,7 +4,6 @@ import {
CREDITS_PER_USD,
COMFY_CREDIT_RATE_CENTS,
centsToCredits,
clampUsd,
creditsToCents,
creditsToUsd,
formatCredits,
@@ -44,21 +43,4 @@ describe('comfyCredits helpers', () => {
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
})
test('formats with compatible fraction digit bounds', () => {
expect(
formatCredits({
value: 12.345,
locale: 'en-US',
numberOptions: { minimumFractionDigits: 4, maximumFractionDigits: 2 }
})
).toBe('12.35')
})
test('clamps USD purchase values into the supported range', () => {
expect(clampUsd(Number.NaN)).toBe(0)
expect(clampUsd(-5)).toBe(1)
expect(clampUsd(42)).toBe(42)
expect(clampUsd(5000)).toBe(1000)
})
})

View File

@@ -70,8 +70,6 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
:disabled="toValue(item.disabled) ?? !item.command"
@select="item.command?.({ originalEvent: $event, item })"
>
<!-- Items declaring an icon key (even empty) keep the slot so labels align
within icon-bearing menus; icon-less menus render labels flush-left. -->
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
<div class="mr-auto truncate" v-text="item.label" />
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />

View File

@@ -24,7 +24,7 @@ function toggleCategory(category: string) {
}
</script>
<template>
<DropdownMenu button-class="icon-[lucide--list-filter]">
<DropdownMenu>
<template #button>
<Button size="icon" :aria-label="$t('g.filter')">
<i class="icon-[lucide--list-filter]" />
@@ -52,7 +52,7 @@ function toggleCategory(category: string) {
>
<span
class="flex-1"
v-text="$t(filterLabels?.[filter] ?? '') ?? filter"
v-text="filterLabels?.[filter] ? $t(filterLabels[filter]) : filter"
/>
<DropdownMenuItemIndicator class="size-4 shrink-0">
<i class="icon-[lucide--check]" />

View File

@@ -117,8 +117,8 @@
</template>
<script setup lang="ts">
import { mapValues } from 'es-toolkit'
import { useEventListener, useLocalStorage } from '@vueuse/core'
import { mapValues } from 'es-toolkit'
import type { MenuItem } from 'primevue/menuitem'
import { DropdownMenuRadioGroup, DropdownMenuRadioItem } from 'reka-ui'
import {

View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { TooltipRootEmits, TooltipRootProps } from 'reka-ui'
import { TooltipRoot, useForwardPropsEmits } from 'reka-ui'
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
const props = defineProps<TooltipRootProps>()
const emits = defineEmits<TooltipRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TooltipRoot v-bind="forwarded">
<slot />
</TooltipRoot>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { TooltipContentEmits, TooltipContentProps } from 'reka-ui'
import {
TooltipArrow,
TooltipContent,
TooltipPortal,
useForwardPropsEmits
} from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { computed } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
defineOptions({
inheritAttrs: false
})
const {
sideOffset = 4,
class: className,
arrowClass,
...restProps
} = defineProps<
TooltipContentProps & {
class?: HTMLAttributes['class']
arrowClass?: HTMLAttributes['class']
}
>()
const emits = defineEmits<TooltipContentEmits>()
const delegatedProps = computed(() => ({
sideOffset,
...restProps
}))
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<TooltipPortal>
<TooltipContent
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'z-50 w-fit rounded-md border bg-base-background px-3 py-1.5 text-sm text-base-foreground shadow-md',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)
"
>
<slot />
<TooltipArrow
:class="cn('fill-base-background', arrowClass)"
:width="10"
:height="5"
/>
</TooltipContent>
</TooltipPortal>
</template>

View File

@@ -0,0 +1,68 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import TooltipHint from './TooltipHint.vue'
import Button from '@/components/ui/button/Button.vue'
const meta: Meta<typeof TooltipHint> = {
title: 'Components/Tooltip/TooltipHint',
component: TooltipHint,
tags: ['autodocs'],
args: {
content: 'Tooltip hint',
side: 'top',
delayDuration: 300,
disabled: false
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { TooltipHint, Button },
setup() {
return { args }
},
template: `
<div class="flex items-center justify-center p-16">
<TooltipHint v-bind="args">
<Button variant="secondary">Hover me</Button>
</TooltipHint>
</div>
`
})
}
export const Disabled: Story = {
args: {
disabled: true,
content: 'Hidden tooltip'
},
render: Default.render
}
export const IconButton: Story = {
args: {
content: 'Set start frame'
},
render: (args) => ({
components: { TooltipHint },
setup() {
return { args }
},
template: `
<div class="flex items-center justify-center p-16">
<TooltipHint v-bind="args">
<button
type="button"
class="flex size-8 cursor-pointer items-center justify-center rounded-lg bg-component-node-widget-background text-component-node-foreground"
aria-label="Set start frame"
>
<i class="icon-[lucide--skip-back] size-4" />
</button>
</TooltipHint>
</div>
`
})
}

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import type { TooltipContentProps } from 'reka-ui'
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
import TooltipContent from '@/components/ui/tooltip/TooltipContent.vue'
import TooltipProvider from '@/components/ui/tooltip/TooltipProvider.vue'
import TooltipTrigger from '@/components/ui/tooltip/TooltipTrigger.vue'
import { cn } from '@comfyorg/tailwind-utils'
const {
content,
side = 'top',
sideOffset = 4,
delayDuration = 300,
disabled = false
} = defineProps<{
content: string
side?: TooltipContentProps['side']
sideOffset?: number
delayDuration?: number
disabled?: boolean
}>()
</script>
<template>
<TooltipProvider :delay-duration="delayDuration">
<Tooltip :disabled="disabled">
<TooltipTrigger as-child>
<slot />
</TooltipTrigger>
<TooltipContent
:side
:side-offset="sideOffset"
:class="
cn(
'rounded-md border border-node-component-tooltip-border bg-node-component-tooltip-surface px-2.5 py-1 text-xs leading-none text-node-component-tooltip shadow-none'
)
"
arrow-class="fill-node-component-tooltip-surface"
>
{{ content }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { TooltipProviderProps } from 'reka-ui'
import { TooltipProvider } from 'reka-ui'
const { ...restProps } = defineProps<TooltipProviderProps>()
</script>
<template>
<TooltipProvider v-bind="restProps">
<slot />
</TooltipProvider>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { TooltipTriggerProps } from 'reka-ui'
import { TooltipTrigger } from 'reka-ui'
const { ...restProps } = defineProps<TooltipTriggerProps>()
</script>
<template>
<TooltipTrigger v-bind="restProps">
<slot />
</TooltipTrigger>
</template>

View File

@@ -0,0 +1,169 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref, toRefs } from 'vue'
import LoadVideoTrimPanel from './LoadVideoTrimPanel.vue'
const SAMPLE_VIDEO =
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4'
type StoryArgs = ComponentPropsAndSlots<typeof LoadVideoTrimPanel> & {
trimEnabled?: boolean
startFrame?: number
endFrame?: number
}
const meta: Meta<StoryArgs> = {
title: 'Components/Video/LoadVideoTrimPanel',
component: LoadVideoTrimPanel,
tags: ['autodocs'],
decorators: [
() => ({
template:
'<div class="w-[350px] rounded-2xl bg-node-component-surface p-2"><story /></div>'
})
],
args: {
videoUrl: SAMPLE_VIDEO,
trimEnabled: false,
startFrame: 0,
endFrame: 400
}
}
export default meta
type Story = StoryObj<typeof meta>
function renderPanel(initialTrimEnabled: boolean) {
return (args: StoryArgs) => ({
components: { LoadVideoTrimPanel },
setup() {
const { videoUrl } = toRefs(args)
const trimEnabled = ref(initialTrimEnabled)
const startFrame = ref(args.startFrame ?? 0)
const endFrame = ref(args.endFrame ?? 400)
const playheadFrame = ref(0)
return {
videoUrl,
trimEnabled,
startFrame,
endFrame,
playheadFrame
}
},
template: `
<LoadVideoTrimPanel
v-model:trim-enabled="trimEnabled"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:playhead-frame="playheadFrame"
:video-url="videoUrl"
/>
`
})
}
export const TrimDisabled: Story = {
render: renderPanel(false)
}
export const TrimEnabled: Story = {
render: renderPanel(true)
}
export const EmptyNoVideo: Story = {
args: {
videoUrl: undefined
},
render: (args) => ({
components: { LoadVideoTrimPanel },
setup() {
const trimEnabled = ref(false)
const startFrame = ref(0)
const endFrame = ref(0)
const playheadFrame = ref(0)
const uploading = ref(false)
function handleBrowse() {
uploading.value = true
setTimeout(() => {
uploading.value = false
}, 1200)
}
return {
args,
trimEnabled,
startFrame,
endFrame,
playheadFrame,
uploading,
handleBrowse
}
},
template: `
<LoadVideoTrimPanel
v-model:trim-enabled="trimEnabled"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:playhead-frame="playheadFrame"
:video-url="args.videoUrl"
:uploading="uploading"
@browse="handleBrowse"
/>
`
})
}
export const EmptyNodeLayout: Story = {
args: {
videoUrl: undefined
},
render: (args) => ({
components: { LoadVideoTrimPanel },
setup() {
const trimEnabled = ref(false)
const startFrame = ref(0)
const endFrame = ref(0)
const playheadFrame = ref(0)
const uploading = ref(false)
return {
args,
trimEnabled,
startFrame,
endFrame,
playheadFrame,
uploading
}
},
template: `
<div class="flex flex-col gap-2">
<div class="px-2">
<label class="mb-1 block text-sm text-muted-foreground">video</label>
<div class="flex h-8 items-center justify-between rounded-lg bg-component-node-widget-background px-2 text-sm text-text-secondary">
<span>Browse asset library</span>
<i class="icon-[lucide--chevron-down] size-4 text-component-node-foreground-secondary" />
</div>
</div>
<LoadVideoTrimPanel
v-model:trim-enabled="trimEnabled"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:playhead-frame="playheadFrame"
:video-url="args.videoUrl"
:uploading="uploading"
/>
</div>
`
})
}
export const LongVideoManyFrames: Story = {
args: {
videoUrl: SAMPLE_VIDEO,
startFrame: 120,
endFrame: 3600
},
render: renderPanel(true)
}

View File

@@ -0,0 +1,446 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import userEvent from '@testing-library/user-event'
import { fireEvent, render, screen } from '@testing-library/vue'
import { defineComponent, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import LoadVideoTrimPanel from './LoadVideoTrimPanel.vue'
vi.mock('@/composables/video/useVideoFilmstrip', () => ({
DEFAULT_VIDEO_FPS: 30,
useVideoFilmstrip: () => ({
thumbnails: ref<string[]>(['data:image/jpeg;base64,one']),
duration: ref(10),
totalFrames: ref(101),
width: ref(1920),
height: ref(1080),
fps: ref(30),
fileSize: ref(5 * 1024 * 1024),
loading: ref(false)
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
increment: 'Increment',
decrement: 'Decrement',
remove: 'Remove'
},
loadVideoTrim: {
trimVideo: 'Trim Video',
startFrame: 'Start Frame',
endFrame: 'End Frame',
setStartFrame: 'Set start frame',
setEndFrame: 'Set end frame',
play: 'Play',
pause: 'Pause',
adjustStartFrame: 'Adjust start frame',
adjustEndFrame: 'Adjust end frame',
duration: 'Duration',
frames: 'Number of Frames',
fileSize: 'File Size',
durationZero: '0s',
durationSeconds: '{count}s',
fileSizeUnknown: '—',
fileSizeBytes: '{count} B',
fileSizeKilobytes: '{count} KB',
fileSizeMegabytes: '{count} MB',
resolution: '{width} × {height}',
dragAndDropVideos: 'Drag and drop videos here to upload',
uploadFromDevice: 'Upload from device',
uploading: 'Uploading…',
loadingVideo: 'Loading video preview'
}
}
}
})
type PanelProps = ComponentProps<typeof LoadVideoTrimPanel>
async function flushPromises() {
await Promise.resolve()
await Promise.resolve()
}
function renderPanel(props: PanelProps) {
return render(LoadVideoTrimPanel, {
props,
global: {
plugins: [i18n]
}
})
}
describe('LoadVideoTrimPanel', () => {
it('shows upload empty state and hides trim controls when no video', () => {
renderPanel({
videoUrl: undefined
})
expect(screen.getByTestId('media-upload-empty')).toBeTruthy()
expect(screen.queryByText('Trim Video')).toBeNull()
})
it('shows trim controls when video is loaded', () => {
renderPanel({
videoUrl: 'https://example.com/video.mp4'
})
expect(screen.queryByTestId('media-upload-empty')).toBeNull()
expect(screen.getByText('Trim Video')).toBeTruthy()
})
it('keeps the filmstrip visible when trim is toggled off', () => {
renderPanel({
videoUrl: 'https://example.com/video.mp4',
trimEnabled: false
})
expect(screen.getByTestId('trim-track')).toBeTruthy()
expect(screen.queryByText('Start Frame')).toBeNull()
expect(screen.queryByText('End Frame')).toBeNull()
})
it('shows drag and drop empty state while not uploading', () => {
renderPanel({
videoUrl: undefined,
uploading: false
})
expect(screen.getByTestId('media-upload-browse-button')).toBeTruthy()
expect(screen.queryByText('Uploading…')).toBeNull()
})
it('shows uploading state only while an upload is in progress', () => {
renderPanel({
videoUrl: undefined,
uploading: true
})
expect(screen.queryByTestId('media-upload-browse-button')).toBeNull()
expect(screen.getByText('Uploading…')).toBeTruthy()
})
it('shows remove button and emits remove when clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderPanel({
videoUrl: 'https://example.com/video.mp4'
})
const removeButton = screen.getByTestId('video-remove-button')
expect(removeButton).toBeTruthy()
expect(removeButton).toHaveAttribute('aria-label', 'Remove')
await user.click(removeButton)
expect(emitted().remove).toHaveLength(1)
})
it('activates remove from keyboard', async () => {
const user = userEvent.setup()
const { emitted } = renderPanel({
videoUrl: 'https://example.com/video.mp4'
})
const removeButton = screen.getByTestId('video-remove-button')
removeButton.focus()
await user.keyboard('{Enter}')
expect(emitted().remove).toHaveLength(1)
})
it('forwards browse event from empty state', async () => {
const user = userEvent.setup()
const { emitted } = renderPanel({
videoUrl: undefined
})
await user.click(screen.getByTestId('media-upload-browse-button'))
expect(emitted().browse).toHaveLength(1)
})
it('keeps playhead when trim edges move without collision', async () => {
const playheadFrame = ref(50)
const startFrame = ref(10)
const endFrame = ref(80)
const Host = defineComponent({
components: { LoadVideoTrimPanel },
setup() {
return {
playheadFrame,
startFrame,
endFrame,
trimEnabled: ref(true),
videoUrl: 'https://example.com/video.mp4'
}
},
template: `
<LoadVideoTrimPanel
v-model:playhead-frame="playheadFrame"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:trim-enabled="trimEnabled"
:video-url="videoUrl"
/>
`
})
render(Host, { global: { plugins: [i18n] } })
startFrame.value = 20
await Promise.resolve()
expect(playheadFrame.value).toBe(50)
})
it('moves playhead when trim edge collides with it', async () => {
const playheadFrame = ref(50)
const startFrame = ref(10)
const endFrame = ref(80)
const Host = defineComponent({
components: { LoadVideoTrimPanel },
setup() {
return {
playheadFrame,
startFrame,
endFrame,
trimEnabled: ref(true),
videoUrl: 'https://example.com/video.mp4'
}
},
template: `
<LoadVideoTrimPanel
v-model:playhead-frame="playheadFrame"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:trim-enabled="trimEnabled"
:video-url="videoUrl"
/>
`
})
render(Host, { global: { plugins: [i18n] } })
startFrame.value = 60
await Promise.resolve()
expect(playheadFrame.value).toBe(60)
})
it('moves playhead when start frame increment passes playhead', async () => {
const user = userEvent.setup()
const playheadFrame = ref(50)
const startFrame = ref(50)
const endFrame = ref(80)
const Host = defineComponent({
components: { LoadVideoTrimPanel },
setup() {
return {
playheadFrame,
startFrame,
endFrame,
trimEnabled: ref(true),
videoUrl: 'https://example.com/video.mp4'
}
},
template: `
<LoadVideoTrimPanel
v-model:playhead-frame="playheadFrame"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:trim-enabled="trimEnabled"
:video-url="videoUrl"
/>
`
})
render(Host, { global: { plugins: [i18n] } })
await user.click(screen.getAllByTestId('increment')[0])
expect(startFrame.value).toBe(51)
expect(playheadFrame.value).toBe(51)
})
it('disables set start and end frame when trim handles are at defaults', () => {
renderPanel({
videoUrl: 'https://example.com/video.mp4',
trimEnabled: true,
startFrame: 0,
endFrame: 100,
playheadFrame: 0
})
expect(screen.getByLabelText('Set start frame')).toBeDisabled()
expect(screen.getByLabelText('Set end frame')).toBeDisabled()
})
it('disables set end frame when trim end is already at the last frame', () => {
renderPanel({
videoUrl: 'https://example.com/video.mp4',
trimEnabled: true,
startFrame: 10,
endFrame: 100,
playheadFrame: 50
})
expect(screen.getByLabelText('Set start frame')).not.toBeDisabled()
expect(screen.getByLabelText('Set end frame')).toBeDisabled()
})
it('resets the start trim handle to the first frame', async () => {
const user = userEvent.setup()
const startFrame = ref(10)
const endFrame = ref(100)
const playheadFrame = ref(50)
const Host = defineComponent({
components: { LoadVideoTrimPanel },
setup() {
return {
playheadFrame,
startFrame,
endFrame,
trimEnabled: ref(true),
videoUrl: 'https://example.com/video.mp4'
}
},
template: `
<LoadVideoTrimPanel
v-model:playhead-frame="playheadFrame"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:trim-enabled="trimEnabled"
:video-url="videoUrl"
/>
`
})
render(Host, { global: { plugins: [i18n] } })
await user.click(screen.getByLabelText('Set start frame'))
expect(startFrame.value).toBe(0)
expect(playheadFrame.value).toBe(0)
})
it('resets the end trim handle to the last frame', async () => {
const user = userEvent.setup()
const startFrame = ref(10)
const endFrame = ref(80)
const playheadFrame = ref(50)
const Host = defineComponent({
components: { LoadVideoTrimPanel },
setup() {
return {
playheadFrame,
startFrame,
endFrame,
trimEnabled: ref(true),
videoUrl: 'https://example.com/video.mp4'
}
},
template: `
<LoadVideoTrimPanel
v-model:playhead-frame="playheadFrame"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:trim-enabled="trimEnabled"
:video-url="videoUrl"
/>
`
})
render(Host, { global: { plugins: [i18n] } })
await user.click(screen.getByLabelText('Set end frame'))
expect(endFrame.value).toBe(100)
expect(playheadFrame.value).toBe(100)
})
it('seeks the video preview when scrubbing the filmstrip', async () => {
const playheadFrame = ref(0)
const startFrame = ref(0)
const endFrame = ref(100)
const Host = defineComponent({
components: { LoadVideoTrimPanel },
setup() {
return {
playheadFrame,
startFrame,
endFrame,
trimEnabled: ref(true),
videoUrl: 'https://example.com/video.mp4'
}
},
template: `
<LoadVideoTrimPanel
v-model:playhead-frame="playheadFrame"
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:trim-enabled="trimEnabled"
:video-url="videoUrl"
/>
`
})
render(Host, { global: { plugins: [i18n] } })
const video = screen.getByTestId('video-preview') as HTMLVideoElement
let currentTime = 0
Object.defineProperty(video, 'currentTime', {
get: () => currentTime,
set: (value: number) => {
currentTime = value
},
configurable: true
})
Object.defineProperty(video, 'duration', {
value: 10,
configurable: true
})
await fireEvent.loadedMetadata(video)
await flushPromises()
await fireEvent.seeked(video)
await flushPromises()
const track = screen.getByTestId('trim-track')
vi.spyOn(track, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 200,
height: 64,
right: 200,
bottom: 64,
x: 0,
y: 0,
toJSON: () => ({})
})
track.setPointerCapture = vi.fn()
// eslint-disable-next-line testing-library/prefer-user-event -- pointer capture scrubbing needs low-level pointer events
await fireEvent.pointerDown(track, {
clientX: 100,
button: 0,
pointerId: 1
})
await flushPromises()
await fireEvent.seeked(video)
await flushPromises()
expect(playheadFrame.value).toBe(50)
expect(currentTime).toBe(5)
})
})

View File

@@ -0,0 +1,501 @@
<template>
<div
class="flex flex-col gap-2"
:class="!videoUrl && 'min-h-0 flex-1 pb-3'"
@pointerdown.stop
>
<MediaUploadEmpty
v-if="!videoUrl"
fill
accept="video/*"
:disabled="uploadDisabled"
:uploading
:on-drag-over
:on-drag-drop
@browse="emit('browse')"
/>
<div
v-else
data-testid="video-preview-container"
class="group relative w-full"
:style="videoAspectRatioStyle"
>
<div
class="relative size-full overflow-hidden rounded-lg bg-node-component-surface"
>
<video
ref="videoRef"
data-testid="video-preview"
:src="videoUrl"
class="size-full object-contain"
preload="auto"
muted
playsinline
@loadedmetadata="handleVideoMetadata"
@timeupdate="handleTimeUpdate"
/>
<div
v-if="filmstripLoading"
class="absolute inset-0 flex flex-col items-center justify-center gap-0 bg-node-component-surface"
data-testid="video-preview-loading"
:aria-busy="true"
:aria-label="t('loadVideoTrim.loadingVideo')"
>
<Loader size="md" variant="loader-circle" />
<p class="text-sm text-muted-foreground">
{{ t('loadVideoTrim.loadingVideo') }}
</p>
</div>
</div>
<TooltipHint v-if="!filmstripLoading" :content="t('g.remove')">
<button
type="button"
data-testid="video-remove-button"
:class="
cn(
removeButtonClass,
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100'
)
"
:aria-label="t('g.remove')"
@pointerdown.stop
@click.stop="emit('remove')"
>
<i class="icon-[lucide--x] size-4" />
</button>
</TooltipHint>
</div>
<div
v-if="videoUrl"
class="grid grid-cols-[minmax(80px,min-content)_minmax(125px,1fr)] gap-1"
>
<WidgetToggleSwitch
v-model="trimEnabled"
class="col-span-full grid grid-cols-subgrid"
:widget="trimToggleWidget"
/>
<VideoFilmstripTrim
v-model:start-frame="startFrame"
v-model:end-frame="endFrame"
v-model:playhead-frame="playheadFrame"
v-model:is-playing="isPlaying"
class="col-span-full mt-2"
:trim-enabled="trimEnabled"
:total-frames="effectiveTotalFrames"
:thumbnails="thumbnails"
@scrub="handleScrub"
/>
<WidgetInputNumberInput
v-if="trimEnabled"
v-model="startFrame"
root-class="col-span-full grid grid-cols-subgrid items-center"
:widget="startFrameWidget"
/>
<WidgetInputNumberInput
v-if="trimEnabled"
v-model="endFrame"
root-class="col-span-full grid grid-cols-subgrid items-center"
:widget="endFrameWidget"
/>
<div v-if="trimEnabled" class="col-span-full grid grid-cols-2 gap-1">
<TooltipHint
:content="t('loadVideoTrim.setStartFrame')"
:disabled="setStartFrameDisabled"
>
<button
type="button"
:class="WidgetInputActionButtonClass"
:disabled="setStartFrameDisabled"
:aria-label="t('loadVideoTrim.setStartFrame')"
@click="setStartFrame"
>
<i class="icon-[lucide--skip-back] size-4" />
</button>
</TooltipHint>
<TooltipHint
:content="t('loadVideoTrim.setEndFrame')"
:disabled="setEndFrameDisabled"
>
<button
type="button"
:class="WidgetInputActionButtonClass"
:disabled="setEndFrameDisabled"
:aria-label="t('loadVideoTrim.setEndFrame')"
@click="setEndFrame"
>
<i class="icon-[lucide--skip-forward] size-4" />
</button>
</TooltipHint>
</div>
<div
class="col-span-full mt-2 grid grid-cols-subgrid gap-y-0.5 border-t border-node-stroke py-2"
>
<div
v-for="row in metadataRows"
:key="row.label"
class="col-span-full grid grid-cols-subgrid py-0.5 text-sm"
>
<span class="truncate text-muted-foreground">{{ row.label }}</span>
<span class="text-right text-base-foreground">{{ row.value }}</span>
</div>
</div>
<p
v-if="resolutionLabel"
class="col-span-full m-0 border-t border-node-stroke py-3 text-center text-sm text-base-foreground"
>
{{ resolutionLabel }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { clamp } from 'es-toolkit'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '@/components/loader/Loader.vue'
import MediaUploadEmpty from '@/components/video/MediaUploadEmpty.vue'
import VideoFilmstripTrim from '@/components/video/VideoFilmstripTrim.vue'
import TooltipHint from '@/components/ui/tooltip/TooltipHint.vue'
import { cn } from '@comfyorg/tailwind-utils'
import {
DEFAULT_VIDEO_FPS,
useVideoFilmstrip
} from '@/composables/video/useVideoFilmstrip'
import { WidgetInputActionButtonClass } from '@/renderer/extensions/vueNodes/widgets/components/layout'
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumberInput.vue'
import WidgetToggleSwitch from '@/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const {
videoUrl,
uploading = false,
uploadDisabled = false,
onDragOver,
onDragDrop
} = defineProps<{
videoUrl?: string
uploading?: boolean
uploadDisabled?: boolean
onDragOver?: (event: DragEvent) => boolean
onDragDrop?: (event: DragEvent) => boolean | Promise<boolean>
}>()
const emit = defineEmits<{
browse: []
remove: []
}>()
const removeButtonClass =
'absolute top-2 right-2 z-10 flex size-8 cursor-pointer items-center justify-center rounded-lg border-0 bg-base-foreground text-base-background shadow-interface transition-colors duration-200 hover:bg-base-foreground/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-base-foreground focus-visible:ring-offset-2 focus-visible:ring-offset-transparent'
const trimEnabled = defineModel<boolean>('trimEnabled', { default: false })
const startFrame = defineModel<number>('startFrame', { default: 0 })
const endFrame = defineModel<number>('endFrame', { default: 0 })
const playheadFrame = defineModel<number>('playheadFrame', { default: 0 })
const { t } = useI18n()
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
const isPlaying = ref(false)
const isSeeking = ref(false)
const videoIntrinsicSize = ref<{ width: number; height: number } | null>(null)
let activeSeekId = 0
const videoUrlRef = computed(() => videoUrl)
const {
thumbnails,
duration,
totalFrames,
width,
height,
fps,
fileSize,
loading: filmstripLoading
} = useVideoFilmstrip(videoUrlRef)
const effectiveTotalFrames = computed(() => Math.max(totalFrames.value, 1))
const frameMax = computed(() => Math.max(totalFrames.value - 1, 0))
const controlsDisabled = computed(() => !trimEnabled.value || !videoUrl)
const setStartFrameDisabled = computed(
() => controlsDisabled.value || startFrame.value <= 0
)
const setEndFrameDisabled = computed(
() => controlsDisabled.value || endFrame.value >= frameMax.value
)
const trimToggleWidget = computed(
(): SimplifiedWidget<boolean> => ({
name: 'trim_enabled',
label: t('loadVideoTrim.trimVideo'),
type: 'toggle',
value: trimEnabled.value
})
)
const startFrameWidget = computed(
(): SimplifiedWidget<number> => ({
name: 'start_frame',
label: t('loadVideoTrim.startFrame'),
type: 'number',
value: startFrame.value,
options: {
min: 0,
max: Math.max(endFrame.value - 1, 0),
step: 1,
step2: 1,
precision: 0,
disabled: !videoUrl
}
})
)
const endFrameWidget = computed(
(): SimplifiedWidget<number> => ({
name: 'end_frame',
label: t('loadVideoTrim.endFrame'),
type: 'number',
value: endFrame.value,
options: {
min: Math.min(startFrame.value + 1, effectiveTotalFrames.value - 1),
max: Math.max(effectiveTotalFrames.value - 1, 0),
step: 1,
step2: 1,
precision: 0,
disabled: !videoUrl
}
})
)
const videoAspectRatioStyle = computed(() => {
const intrinsic = videoIntrinsicSize.value
const aspectWidth = width.value || intrinsic?.width
const aspectHeight = height.value || intrinsic?.height
if (aspectWidth && aspectHeight) {
return { aspectRatio: `${aspectWidth} / ${aspectHeight}` }
}
return { aspectRatio: '16 / 9' }
})
const metadataRows = computed(() => [
{
label: t('loadVideoTrim.duration'),
value: formatDuration(duration.value)
},
{
label: t('loadVideoTrim.frames'),
value: String(effectiveTotalFrames.value)
},
{
label: t('loadVideoTrim.fileSize'),
value: formatFileSize(fileSize.value)
}
])
const resolutionLabel = computed(() => {
const intrinsic = videoIntrinsicSize.value
const displayWidth = width.value || intrinsic?.width
const displayHeight = height.value || intrinsic?.height
if (!displayWidth || !displayHeight) return ''
return t('loadVideoTrim.resolution', {
width: displayWidth,
height: displayHeight
})
})
watch(
() => videoUrl,
() => {
startFrame.value = 0
playheadFrame.value = 0
endFrame.value = 0
isPlaying.value = false
videoIntrinsicSize.value = null
}
)
watch(
totalFrames,
(frames) => {
if (!videoUrl || frames <= 0) return
const lastFrame = Math.max(frames - 1, 0)
if (endFrame.value === 0 || endFrame.value > lastFrame) {
endFrame.value = lastFrame
}
playheadFrame.value = clamp(playheadFrame.value, 0, frameMax.value)
},
{ immediate: true }
)
watch([startFrame, endFrame], ([start, end]) => {
if (start >= end && end > 0) {
startFrame.value = Math.max(end - 1, 0)
}
resolvePlayheadTrimCollision()
})
watch(isPlaying, (playing) => {
void handlePlaybackChange(playing)
})
async function handlePlaybackChange(playing: boolean) {
const video = videoRef.value
if (!video) return
if (playing) {
const startAt = trimEnabled.value
? clamp(playheadFrame.value, startFrame.value, endFrame.value)
: clamp(playheadFrame.value, 0, frameMax.value)
await seekPreviewToFrame(startAt)
if (!isPlaying.value) return
try {
await video.play()
} catch {
isPlaying.value = false
}
} else {
video.pause()
}
}
function frameToTime(frame: number) {
if (duration.value > 0 && frameMax.value > 0) {
return (frame / frameMax.value) * duration.value
}
return frame / (fps.value || DEFAULT_VIDEO_FPS)
}
function clampSeekTime(video: HTMLVideoElement, time: number) {
if (!Number.isFinite(video.duration) || video.duration <= 0) {
return Math.max(time, 0)
}
return clamp(time, 0, Math.max(video.duration - 0.001, 0))
}
function waitForVideoSeek(video: HTMLVideoElement): Promise<void> {
return new Promise((resolve) => {
const finish = () => {
video.removeEventListener('seeked', finish)
video.removeEventListener('error', finish)
resolve()
}
video.addEventListener('seeked', finish, { once: true })
video.addEventListener('error', finish, { once: true })
})
}
async function seekPreviewToFrame(frame: number) {
const video = videoRef.value
if (!video) return
const clamped = clamp(frame, 0, frameMax.value)
playheadFrame.value = clamped
const targetTime = clampSeekTime(video, frameToTime(clamped))
if (Math.abs(video.currentTime - targetTime) <= 0.0001) return
const seekId = ++activeSeekId
isSeeking.value = true
video.currentTime = targetTime
await waitForVideoSeek(video)
if (seekId === activeSeekId) {
isSeeking.value = false
}
}
function resolvePlayheadTrimCollision() {
if (!trimEnabled.value) return
const start = startFrame.value
const end = endFrame.value
const previous = playheadFrame.value
if (previous < start) {
playheadFrame.value = start
} else if (previous > end) {
playheadFrame.value = end
}
if (!isPlaying.value && playheadFrame.value !== previous) {
void seekPreviewToFrame(playheadFrame.value)
}
}
function handleScrub(frame: number) {
isPlaying.value = false
void seekPreviewToFrame(frame)
}
function handleVideoMetadata() {
const video = videoRef.value
if (video?.videoWidth && video.videoHeight) {
videoIntrinsicSize.value = {
width: video.videoWidth,
height: video.videoHeight
}
}
void seekPreviewToFrame(playheadFrame.value)
}
function timeToFrame(time: number) {
if (duration.value > 0 && frameMax.value > 0) {
return Math.round((time / duration.value) * frameMax.value)
}
return Math.round(time * (fps.value || DEFAULT_VIDEO_FPS))
}
function handleTimeUpdate() {
const video = videoRef.value
if (!video || !isPlaying.value || isSeeking.value) return
const frame = timeToFrame(video.currentTime)
const minFrame = trimEnabled.value ? startFrame.value : 0
const maxFrame = trimEnabled.value ? endFrame.value : frameMax.value
playheadFrame.value = clamp(frame, minFrame, maxFrame)
if (frame >= maxFrame) {
isPlaying.value = false
void seekPreviewToFrame(maxFrame)
}
}
function setStartFrame() {
isPlaying.value = false
startFrame.value = 0
void seekPreviewToFrame(0)
}
function setEndFrame() {
isPlaying.value = false
endFrame.value = frameMax.value
void seekPreviewToFrame(frameMax.value)
}
function formatDuration(seconds: number) {
if (!seconds) return t('loadVideoTrim.durationZero')
return t('loadVideoTrim.durationSeconds', { count: Math.round(seconds) })
}
function formatFileSize(bytes?: number) {
if (bytes == null) return t('loadVideoTrim.fileSizeUnknown')
if (bytes < 1024) {
return t('loadVideoTrim.fileSizeBytes', { count: bytes })
}
if (bytes < 1024 * 1024) {
return t('loadVideoTrim.fileSizeKilobytes', {
count: Math.round(bytes / 1024)
})
}
return t('loadVideoTrim.fileSizeMegabytes', {
count: Number((bytes / (1024 * 1024)).toFixed(1))
})
}
</script>

View File

@@ -0,0 +1,80 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref } from 'vue'
import MediaUploadEmpty from './MediaUploadEmpty.vue'
type StoryArgs = ComponentPropsAndSlots<typeof MediaUploadEmpty>
const meta: Meta<StoryArgs> = {
title: 'Components/Video/MediaUploadEmpty',
component: MediaUploadEmpty,
tags: ['autodocs'],
decorators: [
() => ({
template:
'<div class="w-[350px] rounded-2xl bg-node-component-surface p-2"><story /></div>'
})
],
args: {
accept: 'video/*',
disabled: false,
uploading: false
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: (args) => ({
components: { MediaUploadEmpty },
setup() {
const uploading = ref(false)
function handleBrowse() {
uploading.value = true
setTimeout(() => {
uploading.value = false
}, 1200)
}
return { args, uploading, handleBrowse }
},
template: `
<MediaUploadEmpty
v-bind="args"
:uploading="uploading"
@browse="handleBrowse"
/>
`
})
}
export const Uploading: Story = {
args: {
uploading: true
}
}
export const Disabled: Story = {
args: {
disabled: true
}
}
export const Hovered: Story = {
render: (args) => ({
components: { MediaUploadEmpty },
setup() {
return { args }
},
template: `
<MediaUploadEmpty
v-bind="args"
class="border-component-node-foreground-secondary bg-component-node-widget-background-hovered"
/>
`
})
}

View File

@@ -0,0 +1,188 @@
import type { ComponentProps } from 'vue-component-type-helpers'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { nextTick, ref, watch } from 'vue'
import { createI18n } from 'vue-i18n'
import { describe, expect, it, vi } from 'vitest'
import MediaUploadEmpty from './MediaUploadEmpty.vue'
vi.mock('@vueuse/core', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
function useDropZone(
target: { value: HTMLElement | null | undefined },
options?:
| {
onDrop?: (files: File[] | null, event: DragEvent) => void
onOver?: (files: File[] | null, event: DragEvent) => void
onLeave?: (files: File[] | null, event: DragEvent) => void
}
| ((files: File[] | null, event: DragEvent) => void)
) {
const isOverDropZone = ref(false)
const resolved =
typeof options === 'function' ? { onDrop: options } : options
watch(
() => target.value,
(element, _, onCleanup) => {
if (!element || !resolved) return
const callbacks = resolved
function onDragOver(event: DragEvent) {
event.preventDefault()
isOverDropZone.value = true
callbacks.onOver?.(Array.from(event.dataTransfer?.files ?? []), event)
}
function onDrop(event: DragEvent) {
event.preventDefault()
isOverDropZone.value = false
callbacks.onDrop?.(Array.from(event.dataTransfer?.files ?? []), event)
}
function onDragLeave(event: DragEvent) {
isOverDropZone.value = false
callbacks.onLeave?.(null, event)
}
element.addEventListener('dragover', onDragOver)
element.addEventListener('drop', onDrop)
element.addEventListener('dragleave', onDragLeave)
onCleanup(() => {
element.removeEventListener('dragover', onDragOver)
element.removeEventListener('drop', onDrop)
element.removeEventListener('dragleave', onDragLeave)
})
},
{ immediate: true }
)
return { isOverDropZone }
}
return { ...actual, useDropZone }
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
loadVideoTrim: {
dragAndDropVideos: 'Drag and drop videos here to upload',
uploadFromDevice: 'Upload from device',
uploading: 'Uploading…'
},
g: {
loading: 'Loading'
}
}
}
})
function dragPayload(files: File[] = []) {
return {
dataTransfer: {
files,
types: ['Files'],
items: files.map((file) => ({
kind: 'file',
type: file.type,
getAsFile: () => file
}))
}
}
}
async function renderEmpty(
props: Partial<ComponentProps<typeof MediaUploadEmpty>> = {}
) {
const result = render(MediaUploadEmpty, {
props: {
accept: 'video/*',
...props
},
global: {
plugins: [i18n]
}
})
await nextTick()
return result
}
async function simulateDrop(
target: HTMLElement,
payload: ReturnType<typeof dragPayload>
) {
await fireEvent.dragOver(target, payload)
await fireEvent.drop(target, payload)
}
describe('MediaUploadEmpty', () => {
it('renders drag-drop prompt and upload button', async () => {
await renderEmpty()
expect(screen.getByText('Drag and drop videos here to upload')).toBeTruthy()
expect(screen.getByTestId('media-upload-browse-button')).toBeTruthy()
expect(screen.getByText('Upload from device')).toBeTruthy()
})
it('emits browse when upload button is clicked', async () => {
const user = userEvent.setup()
const { emitted } = await renderEmpty()
await user.click(screen.getByTestId('media-upload-browse-button'))
expect(emitted().browse).toHaveLength(1)
})
it('emits upload with video files on drop', async () => {
const { emitted } = await renderEmpty()
const zone = screen.getByTestId('media-upload-empty')
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' })
await simulateDrop(zone, dragPayload([file]))
expect(emitted().upload).toHaveLength(1)
expect((emitted().upload[0] as [File[]])[0][0].name).toBe('clip.mp4')
})
it('delegates drag events to provided handlers', async () => {
const onDragOver = vi.fn(() => true)
const onDragDrop = vi.fn(() => true)
await renderEmpty({ onDragOver, onDragDrop })
const zone = screen.getByTestId('media-upload-empty')
await simulateDrop(zone, dragPayload([]))
expect(onDragOver).toHaveBeenCalled()
expect(onDragDrop).toHaveBeenCalled()
})
it('does not emit browse when disabled', async () => {
const user = userEvent.setup()
const { emitted } = await renderEmpty({ disabled: true })
await user.click(screen.getByTestId('media-upload-browse-button'))
expect(emitted().browse).toBeUndefined()
})
it('shows uploading spinner and hides upload controls while processing', async () => {
await renderEmpty({
uploading: true
})
expect(screen.getByText('Uploading…')).toBeTruthy()
expect(screen.queryByText('Drag and drop videos here to upload')).toBeNull()
expect(screen.queryByTestId('media-upload-browse-button')).toBeNull()
})
it('does not emit browse while uploading', async () => {
await renderEmpty({ uploading: true })
expect(screen.queryByTestId('media-upload-browse-button')).toBeNull()
})
})

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { useDropZone } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Loader from '@/components/loader/Loader.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
const {
accept = 'video/*',
disabled = false,
uploading = false,
fill = false,
onDragOver,
onDragDrop
} = defineProps<{
accept?: string
disabled?: boolean
uploading?: boolean
fill?: boolean
onDragOver?: (event: DragEvent) => boolean
onDragDrop?: (event: DragEvent) => boolean | Promise<boolean>
}>()
const emit = defineEmits<{
browse: []
upload: [files: File[]]
}>()
const { t } = useI18n()
const dropZoneRef = ref<HTMLElement | null>(null)
const canAcceptDrop = ref(false)
const isInteractionDisabled = computed(() => disabled || uploading)
function matchesAccept(file: File) {
if (!accept || accept === '*/*') return true
return accept.split(',').some((pattern) => {
const trimmed = pattern.trim()
if (trimmed.endsWith('/*')) {
return file.type.startsWith(trimmed.slice(0, -1))
}
return file.type === trimmed
})
}
const { isOverDropZone } = useDropZone(dropZoneRef, {
onDrop: (files, event) => {
event?.stopPropagation()
if (isInteractionDisabled.value) return
if (onDragDrop && event) {
void Promise.resolve(onDragDrop(event)).catch(() => {})
} else {
const droppedFiles =
files && files.length > 0
? files
: Array.from(event?.dataTransfer?.files ?? [])
const accepted = droppedFiles.filter(matchesAccept)
if (accepted.length) emit('upload', accepted)
}
canAcceptDrop.value = false
},
onOver: (_, event) => {
if (isInteractionDisabled.value) {
canAcceptDrop.value = false
return
}
if (onDragOver && event) {
canAcceptDrop.value = onDragOver(event)
return
}
const items = event?.dataTransfer?.items
canAcceptDrop.value = items
? Array.from(items).some(
(item) => item.kind === 'file' && matchesAcceptType(item.type)
)
: false
},
onLeave: () => {
canAcceptDrop.value = false
}
})
function matchesAcceptType(type: string) {
if (!accept || accept === '*/*') return true
return accept.split(',').some((pattern) => {
const trimmed = pattern.trim()
if (trimmed.endsWith('/*')) {
return type.startsWith(trimmed.slice(0, -1))
}
return type === trimmed
})
}
const isHovered = computed(
() =>
!isInteractionDisabled.value && canAcceptDrop.value && isOverDropZone.value
)
function handleBrowseClick() {
if (isInteractionDisabled.value) return
emit('browse')
}
</script>
<template>
<div
ref="dropZoneRef"
data-testid="media-upload-empty"
:class="
cn(
'flex min-h-75 w-full min-w-75 flex-col items-center justify-center gap-0 rounded-lg border border-dashed border-node-component-border bg-node-component-surface px-6 py-8 transition-colors',
fill && 'size-full flex-1',
isHovered &&
'border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
)
"
>
<template v-if="uploading">
<Loader size="md" variant="loader-circle" />
<p class="text-sm text-muted-foreground">
{{ t('loadVideoTrim.uploading') }}
</p>
</template>
<template v-else>
<i
class="icon-[lucide--upload] size-8 text-muted-foreground"
aria-hidden="true"
/>
<p class="max-w-48 text-center text-sm/snug text-muted-foreground">
{{ t('loadVideoTrim.dragAndDropVideos') }}
</p>
<Button
variant="inverted"
size="lg"
class="min-w-40"
:disabled="disabled"
data-testid="media-upload-browse-button"
@click="handleBrowseClick"
>
{{ t('loadVideoTrim.uploadFromDevice') }}
</Button>
</template>
</div>
</template>

View File

@@ -0,0 +1,367 @@
/* eslint-disable testing-library/prefer-user-event -- pointer capture scrubbing needs low-level pointer events */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { Ref } from 'vue'
const { activeHandle } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref: createRef } = require('vue')
return {
activeHandle: createRef(null) as Ref<'min' | 'max' | 'midpoint' | null>
}
})
vi.mock('@/composables/useRangeEditor', () => ({
useRangeEditor: () => ({
startDrag: vi.fn(),
activeHandle
})
}))
import type { ComponentProps } from 'vue-component-type-helpers'
import { fireEvent, render, screen } from '@testing-library/vue'
import { createI18n } from 'vue-i18n'
import VideoFilmstripTrim from './VideoFilmstripTrim.vue'
import { timelineInsetLeftStyle } from './timelineInsetStyle'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
loadVideoTrim: {
play: 'Play',
pause: 'Pause',
loadingFilmstrip: 'Loading filmstrip…',
adjustStartFrame: 'Adjust start frame',
adjustEndFrame: 'Adjust end frame'
}
}
}
})
type FilmstripProps = ComponentProps<typeof VideoFilmstripTrim>
function expectedFrameAt(clientX: number, width = 200, frameMax = 100) {
const contentWidth = Math.max(width - 32, 1)
const norm = Math.min(Math.max((clientX - 16) / contentWidth, 0), 1)
return Math.round(norm * frameMax)
}
function renderFilmstrip(props: FilmstripProps) {
return render(VideoFilmstripTrim, {
props,
global: {
plugins: [i18n]
}
})
}
function mockTrackRect() {
const track = screen.getByTestId('trim-track')
vi.spyOn(track, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
width: 200,
height: 64,
right: 200,
bottom: 64,
x: 0,
y: 0,
toJSON: () => ({})
})
return track
}
describe('VideoFilmstripTrim', () => {
beforeEach(() => {
activeHandle.value = null
})
it('insets the filmstrip track by handle width on each side', () => {
renderFilmstrip({
totalFrames: 100,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 99,
playheadFrame: 0,
disabled: false
})
const filmstrip = screen.getByTestId('filmstrip-track')
expect(filmstrip.style.left).toBe('16px')
expect(filmstrip.style.right).toBe('16px')
})
it('prevents filmstrip thumbnails from being dragged', () => {
renderFilmstrip({
totalFrames: 100,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 99,
playheadFrame: 0,
disabled: false
})
expect(
screen.getByTestId('filmstrip-thumbnail').getAttribute('draggable')
).toBe('false')
})
it('shows whole frame number in tooltip while dragging end handle', () => {
activeHandle.value = 'max'
renderFilmstrip({
totalFrames: 401,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 400,
playheadFrame: 0,
disabled: false
})
expect(screen.getByTestId('trim-handle-tooltip')).toHaveTextContent('400')
expect(timelineInsetLeftStyle(1).left).toBe(
'calc(1 * (100% - 2rem) + 1rem)'
)
})
it('shows whole frame number in tooltip while dragging start handle', () => {
activeHandle.value = 'min'
renderFilmstrip({
totalFrames: 401,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 120,
endFrame: 400,
playheadFrame: 120,
disabled: false
})
expect(screen.getByTestId('trim-handle-tooltip')).toHaveTextContent('120')
expect(timelineInsetLeftStyle(120 / 400).left).toBe(
'calc(0.3 * (100% - 2rem) + 1rem)'
)
})
it('positions the playhead on the timeline', () => {
renderFilmstrip({
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 100,
playheadFrame: 50,
disabled: false
})
expect(screen.getByTestId('playhead')).toBeTruthy()
expect(timelineInsetLeftStyle(50 / 100).left).toBe(
'calc(0.5 * (100% - 2rem) + 1rem)'
)
})
it('scrubs to the clicked frame on the filmstrip', async () => {
const playheadFrame = ref(0)
const { emitted } = render(VideoFilmstripTrim, {
props: {
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 100,
playheadFrame: 0,
disabled: false,
'onUpdate:playheadFrame': (value: number) => {
playheadFrame.value = value
}
},
global: {
plugins: [i18n]
}
})
const track = mockTrackRect()
await fireEvent.pointerDown(track, { clientX: 100, button: 0 })
expect(playheadFrame.value).toBe(expectedFrameAt(100))
expect(emitted().scrub).toEqual([[expectedFrameAt(100)]])
})
it('clamps scrubbing to the trim selection when trim is enabled', async () => {
const playheadFrame = ref(50)
const { emitted } = render(VideoFilmstripTrim, {
props: {
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 10,
endFrame: 80,
playheadFrame: 50,
disabled: false,
'onUpdate:playheadFrame': (value: number) => {
playheadFrame.value = value
}
},
global: {
plugins: [i18n]
}
})
const track = mockTrackRect()
await fireEvent.pointerDown(track, { clientX: 20, button: 0 })
expect(playheadFrame.value).toBe(10)
expect(emitted().scrub).toEqual([[10]])
await fireEvent.pointerDown(track, { clientX: 180, button: 0 })
expect(playheadFrame.value).toBe(80)
expect(emitted().scrub).toEqual([[10], [80]])
})
it('updates playhead while dragging across the filmstrip', async () => {
const playheadFrame = ref(0)
const { emitted } = render(VideoFilmstripTrim, {
props: {
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 100,
playheadFrame: 0,
disabled: false,
'onUpdate:playheadFrame': (value: number) => {
playheadFrame.value = value
}
},
global: {
plugins: [i18n]
}
})
const track = mockTrackRect()
track.setPointerCapture = vi.fn()
await fireEvent.pointerDown(track, { clientX: 40, button: 0, pointerId: 1 })
await fireEvent.pointerMove(track, {
clientX: 120,
button: 0,
pointerId: 1
})
expect(playheadFrame.value).toBe(expectedFrameAt(120))
expect(emitted().scrub).toEqual([
[expectedFrameAt(40)],
[expectedFrameAt(120)]
])
})
it('shows the frame number in a tooltip while scrubbing', async () => {
const playheadFrame = ref(0)
render(VideoFilmstripTrim, {
props: {
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 0,
endFrame: 100,
playheadFrame: 0,
disabled: false,
'onUpdate:playheadFrame': (value: number) => {
playheadFrame.value = value
}
},
global: {
plugins: [i18n]
}
})
const track = mockTrackRect()
track.setPointerCapture = vi.fn()
expect(screen.queryByTestId('scrub-tooltip')).toBeNull()
await fireEvent.pointerDown(track, {
clientX: 120,
button: 0,
pointerId: 1
})
expect(screen.getByTestId('scrub-tooltip')).toHaveTextContent(
String(expectedFrameAt(120))
)
await fireEvent.pointerUp(track, { pointerId: 1 })
expect(screen.queryByTestId('scrub-tooltip')).toBeNull()
})
it('renders trim handles when enabled', () => {
renderFilmstrip({
totalFrames: 100,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 10,
endFrame: 80,
playheadFrame: 10,
disabled: false
})
expect(screen.getByTestId('handle-start')).toBeTruthy()
expect(screen.getByTestId('handle-end')).toBeTruthy()
})
it('hides trim handles when disabled', () => {
renderFilmstrip({
totalFrames: 100,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 10,
endFrame: 80,
playheadFrame: 10,
disabled: true
})
expect(screen.queryByTestId('handle-start')).toBeNull()
expect(screen.queryByTestId('handle-end')).toBeNull()
})
it('hides trim selection UI when trim is toggled off', () => {
renderFilmstrip({
totalFrames: 100,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 10,
endFrame: 80,
playheadFrame: 10,
trimEnabled: false
})
expect(screen.getByTestId('playhead')).toBeTruthy()
expect(screen.getByTestId('filmstrip-track').style.left).toBe('16px')
expect(screen.getByTestId('filmstrip-track').style.right).toBe('16px')
expect(screen.queryByTestId('handle-start')).toBeNull()
expect(screen.queryByTestId('handle-end')).toBeNull()
})
it('scrubs across the full timeline when trim is toggled off', async () => {
const playheadFrame = ref(0)
const { emitted } = render(VideoFilmstripTrim, {
props: {
totalFrames: 101,
thumbnails: ['data:image/jpeg;base64,one'],
startFrame: 10,
endFrame: 80,
playheadFrame: 0,
trimEnabled: false,
'onUpdate:playheadFrame': (value: number) => {
playheadFrame.value = value
}
},
global: {
plugins: [i18n]
}
})
const track = mockTrackRect()
await fireEvent.pointerDown(track, { clientX: 100, button: 0 })
expect(playheadFrame.value).toBe(expectedFrameAt(100))
expect(emitted().scrub).toEqual([[expectedFrameAt(100)]])
})
})

View File

@@ -0,0 +1,365 @@
<template>
<div class="flex h-16 w-full items-stretch gap-px" @pointerdown.stop>
<button
type="button"
:class="
cn(
'flex w-14 shrink-0 items-center justify-center rounded-l-lg border-none bg-component-node-widget-background px-4 text-muted-foreground',
!disabled &&
'cursor-pointer hover:bg-component-node-widget-background-hovered',
disabled && 'cursor-default opacity-50'
)
"
:disabled="disabled"
:aria-label="
isPlaying ? t('loadVideoTrim.pause') : t('loadVideoTrim.play')
"
@click="togglePlay"
>
<i
:class="
cn(
isPlaying ? 'icon-[lucide--pause]' : 'icon-[lucide--play]',
!isPlaying && 'ml-0.5',
'size-5'
)
"
/>
</button>
<div
ref="trackRef"
data-testid="trim-track"
:class="
cn(
'relative min-w-0 flex-1 rounded-r-lg bg-component-node-widget-background',
isDraggingTimeline ? 'cursor-ew-resize' : 'cursor-default'
)
"
@pointerdown.stop="startScrubDrag"
@contextmenu.prevent.stop
>
<div
v-if="isScrubDragging"
data-testid="scrub-tooltip"
class="pointer-events-none absolute bottom-full z-30 mb-1 flex -translate-x-1/2 flex-col items-center"
:style="playheadStyle"
>
<span
class="rounded-lg bg-interface-menu-surface px-2.5 py-1 text-sm font-semibold text-base-foreground tabular-nums"
>
{{ playheadFrame }}
</span>
<span
class="size-0 border-x-[5px] border-t-[5px] border-x-transparent border-t-interface-menu-surface"
/>
</div>
<div
v-if="trimEnabled && (activeHandle === 'min' || activeHandle === 'max')"
data-testid="trim-handle-tooltip"
class="pointer-events-none absolute bottom-full z-10 mb-1 flex -translate-x-1/2 flex-col items-center"
:style="activeHandleTooltipStyle"
>
<span
class="rounded-lg bg-interface-menu-surface px-2.5 py-1 text-sm font-semibold text-base-foreground tabular-nums"
>
{{ activeHandleFrame }}
</span>
<span
class="size-0 border-x-[5px] border-t-[5px] border-x-transparent border-t-interface-menu-surface"
/>
</div>
<div
data-testid="filmstrip-track"
class="pointer-events-none absolute top-2 flex h-12 items-stretch overflow-hidden"
:style="{
left: `${HANDLE_WIDTH_PX}px`,
right: `${HANDLE_WIDTH_PX}px`
}"
aria-hidden="true"
>
<img
v-for="(thumbnail, index) in thumbnails"
:key="index"
data-testid="filmstrip-thumbnail"
:src="thumbnail"
alt=""
draggable="false"
class="h-full w-auto shrink-0 select-none"
/>
<div
v-if="isFilmstripLoading"
class="flex size-full items-stretch gap-px overflow-hidden"
data-testid="filmstrip-skeleton"
:aria-busy="true"
:aria-label="t('loadVideoTrim.loadingFilmstrip')"
>
<Skeleton
v-for="index in FILMSTRIP_SAMPLE_COUNT"
:key="index"
class="h-full min-w-10 flex-1 rounded-none"
/>
</div>
</div>
<div
v-if="trimEnabled && startNorm > 0"
class="pointer-events-none absolute inset-y-0 left-0 bg-black/50"
:style="leftDimStyle"
/>
<div
v-if="trimEnabled && endNorm < 1"
class="pointer-events-none absolute inset-y-0 right-0 bg-black/50"
:style="rightDimStyle"
/>
<div
v-if="trimEnabled"
class="pointer-events-none absolute inset-y-0 flex"
:style="selectionStyle"
>
<button
v-if="!disabled && totalFrames > 1"
type="button"
data-testid="handle-start"
:class="
cn(
'pointer-events-auto flex w-4 shrink-0 cursor-ew-resize',
'items-center justify-center bg-video-trim-selection-background',
'rounded-l-lg border-none p-0'
)
"
:aria-label="t('loadVideoTrim.adjustStartFrame')"
@pointerdown.stop="startDrag('min', $event)"
>
<span class="h-4 w-px rounded-full bg-secondary-background" />
</button>
<div class="flex min-w-0 flex-1 flex-col">
<div :class="cn('h-2 shrink-0', trimSelectionBarClass)" />
<div class="h-12 shrink-0" />
<div :class="cn('h-2 shrink-0', trimSelectionBarClass)" />
</div>
<button
v-if="!disabled && totalFrames > 1"
type="button"
data-testid="handle-end"
:class="
cn(
'pointer-events-auto flex w-4 shrink-0 cursor-ew-resize',
'items-center justify-center bg-video-trim-selection-background',
'rounded-r-lg border-none p-0'
)
"
:aria-label="t('loadVideoTrim.adjustEndFrame')"
@pointerdown.stop="startDrag('max', $event)"
>
<span class="h-4 w-px rounded-full bg-secondary-background" />
</button>
</div>
<div
data-testid="playhead"
class="absolute top-2 z-20 flex h-12 w-3 -translate-x-1/2 cursor-ew-resize touch-none items-stretch justify-center"
:style="playheadStyle"
@pointerdown.stop="startScrubDrag"
>
<div
class="pointer-events-none w-0.5 bg-video-trim-playhead-background"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, toRef, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { clamp } from 'es-toolkit'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import { timelineInsetLeftStyle } from '@/components/video/timelineInsetStyle'
import { FILMSTRIP_SAMPLE_COUNT } from '@/composables/video/useVideoFilmstrip'
import { useRangeEditor } from '@/composables/useRangeEditor'
import type { RangeValue } from '@/lib/litegraph/src/types/widgets'
import { denormalize } from '@/utils/mathUtil'
import { cn } from '@comfyorg/tailwind-utils'
const HANDLE_WIDTH_PX = 16
const {
totalFrames,
thumbnails,
disabled = false,
trimEnabled = true
} = defineProps<{
totalFrames: number
thumbnails: string[]
disabled?: boolean
trimEnabled?: boolean
}>()
const startFrame = defineModel<number>('startFrame', { required: true })
const endFrame = defineModel<number>('endFrame', { required: true })
const playheadFrame = defineModel<number>('playheadFrame', { required: true })
const isPlaying = defineModel<boolean>('isPlaying', { default: false })
const emit = defineEmits<{
scrub: [frame: number]
}>()
const { t } = useI18n()
const trackRef = useTemplateRef<HTMLDivElement>('trackRef')
const isScrubDragging = ref(false)
const frameMax = computed(() => Math.max(totalFrames - 1, 0))
const rangeValue = computed<RangeValue>({
get: () => ({
min: startFrame.value,
max: endFrame.value
}),
set: (value) => {
startFrame.value = Math.round(value.min)
endFrame.value = Math.round(value.max)
}
})
const contentInsetX = computed(() => HANDLE_WIDTH_PX)
const { startDrag, activeHandle } = useRangeEditor({
trackRef,
modelValue: rangeValue,
valueMin: toRef(() => 0),
valueMax: frameMax,
showMidpoint: toRef(() => false),
contentInsetX
})
const isDraggingTimeline = computed(
() => isScrubDragging.value || activeHandle.value !== null
)
const isFilmstripLoading = computed(() => thumbnails.length === 0)
const trimSelectionBarClass = computed(() =>
isFilmstripLoading.value
? 'bg-component-node-widget-background'
: 'bg-video-trim-selection-background'
)
function pointerToFrame(event: PointerEvent) {
const el = trackRef.value
if (!el) return playheadFrame.value
const rect = el.getBoundingClientRect()
const inset = HANDLE_WIDTH_PX
const contentWidth = Math.max(rect.width - 2 * inset, 1)
const normalized = clamp(
(event.clientX - rect.left - inset) / contentWidth,
0,
1
)
return Math.round(denormalize(normalized, 0, frameMax.value))
}
const scrubFrameMin = computed(() => (trimEnabled ? startFrame.value : 0))
const scrubFrameMax = computed(() =>
trimEnabled ? endFrame.value : frameMax.value
)
function scrubToFrame(frame: number) {
const clamped = clamp(frame, scrubFrameMin.value, scrubFrameMax.value)
playheadFrame.value = clamped
emit('scrub', clamped)
}
function updateScrubFromPointer(event: PointerEvent) {
const frame = pointerToFrame(event)
if (frame === playheadFrame.value) return
scrubToFrame(frame)
}
let cleanupScrubDrag: (() => void) | null = null
function startScrubDrag(event: PointerEvent) {
if (disabled || totalFrames <= 1 || event.button !== 0) return
const el = trackRef.value
if (!el) return
cleanupScrubDrag?.()
isScrubDragging.value = true
scrubToFrame(pointerToFrame(event))
el.setPointerCapture(event.pointerId)
const onMove = (moveEvent: PointerEvent) => {
updateScrubFromPointer(moveEvent)
}
const endDrag = () => {
isScrubDragging.value = false
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', endDrag)
el.removeEventListener('lostpointercapture', endDrag)
cleanupScrubDrag = null
}
cleanupScrubDrag = endDrag
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', endDrag)
el.addEventListener('lostpointercapture', endDrag)
}
onBeforeUnmount(() => {
isScrubDragging.value = false
cleanupScrubDrag?.()
})
const startNorm = computed(() =>
frameMax.value <= 0 ? 0 : startFrame.value / frameMax.value
)
const endNorm = computed(() =>
frameMax.value <= 0 ? 1 : endFrame.value / frameMax.value
)
const playheadNorm = computed(() =>
frameMax.value <= 0 ? 0 : playheadFrame.value / frameMax.value
)
const playheadStyle = computed(() => timelineInsetLeftStyle(playheadNorm.value))
const leftDimStyle = computed(() => ({
width: `calc(${startNorm.value} * (100% - 2rem))`
}))
const rightDimStyle = computed(() => ({
width: `calc(${1 - endNorm.value} * (100% - 2rem))`
}))
const selectionStyle = computed(() => ({
left: `calc(${startNorm.value} * (100% - 2rem))`,
width: `calc((${endNorm.value} - ${startNorm.value}) * (100% - 2rem) + 2rem)`
}))
const activeHandleFrame = computed(() => {
if (activeHandle.value === 'min') return startFrame.value
if (activeHandle.value === 'max') return endFrame.value
return 0
})
const activeHandleTooltipStyle = computed(() => {
const norm = activeHandle.value === 'min' ? startNorm.value : endNorm.value
return timelineInsetLeftStyle(norm)
})
function togglePlay() {
if (disabled) return
isPlaying.value = !isPlaying.value
}
</script>

View File

@@ -0,0 +1,5 @@
export function timelineInsetLeftStyle(normalized: number) {
return {
left: `calc(${normalized} * (100% - 2rem) + 1rem)`
}
}

View File

@@ -34,22 +34,17 @@ describe('useSelectionToolboxPosition', () => {
canvasStore = useCanvasStore()
})
function renderToolboxForSelection(
items: Iterable<Positionable>,
state: Partial<LGraphCanvas['state']> = {},
ds: Partial<LGraphCanvas['ds']> = {}
) {
function renderToolboxForSelection(item: Positionable) {
canvasStore.canvas = markRaw({
canvas: document.createElement('canvas'),
ds: {
offset: ds.offset ?? [0, 0],
scale: ds.scale ?? 1
offset: [0, 0],
scale: 1
},
selectedItems: new Set(items),
selectedItems: new Set([item]),
state: {
draggingItems: false,
selectionChanged: true,
...state
selectionChanged: true
}
} as Partial<LGraphCanvas> as LGraphCanvas)
@@ -74,7 +69,7 @@ describe('useSelectionToolboxPosition', () => {
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group])
const { toolbox, unmount } = renderToolboxForSelection(group)
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
@@ -86,64 +81,11 @@ describe('useSelectionToolboxPosition', () => {
node.pos = [100, 200]
node.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([node])
const { toolbox, unmount } = renderToolboxForSelection(node)
expect(toolbox.style.getPropertyValue('--tb-y')).toBe(
`${190 - LiteGraph.NODE_TITLE_HEIGHT}px`
)
unmount()
})
it('does not set coordinates when selection is empty', () => {
const { toolbox, unmount } = renderToolboxForSelection([])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('does not set coordinates while selected items are being dragged', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [160, 80]
const { toolbox, unmount } = renderToolboxForSelection([group], {
draggingItems: true
})
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('')
unmount()
})
it('positions multiple selected items from their union bounds', () => {
const first = new LGraphGroup('First', 1)
first.pos = [100, 200]
first.size = [100, 40]
const second = new LGraphGroup('Second', 2)
second.pos = [300, 260]
second.size = [50, 40]
const { toolbox, unmount } = renderToolboxForSelection([first, second])
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('270px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('190px')
unmount()
})
it('applies canvas scale and offset to screen coordinates', () => {
const group = new LGraphGroup('Group', 1)
group.pos = [100, 200]
group.size = [100, 40]
const { toolbox, unmount } = renderToolboxForSelection(
[group],
{},
{ offset: [10, 20], scale: 2 }
)
expect(toolbox.style.getPropertyValue('--tb-x')).toBe('360px')
expect(toolbox.style.getPropertyValue('--tb-y')).toBe('420px')
unmount()
})
})

View File

@@ -1,215 +0,0 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
import { useGroupMenuOptions } from '@/composables/graph/useGroupMenuOptions'
const { canvas, captureCanvasState, isLightTheme, refreshCanvas, settings } =
vi.hoisted(() => ({
canvas: { setDirty: vi.fn() },
captureCanvasState: vi.fn(),
isLightTheme: { value: false },
refreshCanvas: vi.fn(),
settings: { 'Comfy.GroupSelectedNodes.Padding': 10 } as Record<
string,
unknown
>
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (k: string) => settings[k] })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { changeTracker: { captureCanvasState } }
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas })
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({ refreshCanvas })
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: [{ value: 1, localizedName: 'Box' }],
colorOptions: [
{ value: { dark: '#111', light: '#eee' }, localizedName: 'Red' }
],
isLightTheme
})
}))
function group(over: Record<string, unknown> = {}): LGraphGroup {
return {
recomputeInsideNodes: vi.fn(),
resizeTo: vi.fn(),
children: [],
graph: { change: vi.fn() },
nodes: [],
...over
} as unknown as LGraphGroup
}
beforeEach(() => {
canvas.setDirty.mockReset()
captureCanvasState.mockReset()
isLightTheme.value = false
refreshCanvas.mockReset()
})
describe('useGroupMenuOptions', () => {
it('fits a group to its nodes, resizing with the configured padding', () => {
const g = group()
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
expect(g.recomputeInsideNodes).toHaveBeenCalled()
expect(g.resizeTo).toHaveBeenCalledWith(g.children, 10)
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('aborts the fit action when recompute throws', () => {
const g = group({
recomputeInsideNodes: vi.fn(() => {
throw new Error('boom')
})
})
useGroupMenuOptions().getFitGroupToNodesOption(g).action?.()
expect(g.resizeTo).not.toHaveBeenCalled()
})
it('applies a shape to all group nodes via the shape submenu', () => {
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
const bump = vi.fn()
const option = useGroupMenuOptions().getGroupShapeOptions(
group({ nodes: [node] }),
bump
)
option.submenu?.[0].action?.()
expect(node.shape).toBe(1)
expect(refreshCanvas).toHaveBeenCalled()
expect(bump).toHaveBeenCalled()
})
it('handles shape actions when a group has no nodes array', () => {
const bump = vi.fn()
useGroupMenuOptions()
.getGroupShapeOptions(group({ nodes: undefined }), bump)
.submenu?.[0].action?.()
expect(refreshCanvas).toHaveBeenCalled()
expect(bump).toHaveBeenCalled()
})
it('applies a color to the group via the color submenu (dark theme)', () => {
const g = group()
const bump = vi.fn()
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect((g as unknown as { color: string }).color).toBe('#111')
expect(bump).toHaveBeenCalled()
})
it('applies a light-theme color to the group via the color submenu', () => {
const g = group()
const bump = vi.fn()
isLightTheme.value = true
useGroupMenuOptions().getGroupColorOptions(g, bump).submenu?.[0].action?.()
expect((g as unknown as { color: string }).color).toBe('#eee')
expect(bump).toHaveBeenCalled()
})
it('returns no mode options for an empty group', () => {
expect(useGroupMenuOptions().getGroupModeOptions(group(), vi.fn())).toEqual(
[]
)
})
it('returns no mode options when a group has no nodes array', () => {
expect(
useGroupMenuOptions().getGroupModeOptions(
group({ nodes: undefined }),
vi.fn()
)
).toEqual([])
})
it('returns no mode options when recomputing group nodes fails', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const options = useGroupMenuOptions().getGroupModeOptions(
group({
recomputeInsideNodes: vi.fn(() => {
throw new Error('boom')
})
}),
vi.fn()
)
expect(options).toEqual([])
expect(warnSpy).toHaveBeenCalledWith(
'Failed to recompute nodes in group for mode options:',
expect.any(Error)
)
})
it('builds mode options for uniform nodes and applies the new mode', () => {
const node = { shape: 0, mode: LGraphEventMode.ALWAYS }
const bump = vi.fn()
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [node] }),
bump
)
expect(options.length).toBeGreaterThan(0)
options[0].action?.()
expect(node.mode).not.toBe(LGraphEventMode.ALWAYS)
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(bump).toHaveBeenCalled()
})
it('offers two alternate modes when all nodes are NEVER', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.NEVER }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers two alternate modes when all nodes are BYPASS', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: LGraphEventMode.BYPASS }] }),
vi.fn()
)
expect(options).toHaveLength(2)
})
it('offers all three modes when nodes have mixed modes', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({
nodes: [
{ mode: LGraphEventMode.ALWAYS },
{ mode: LGraphEventMode.NEVER }
]
}),
vi.fn()
)
expect(options).toHaveLength(3)
})
it('offers all three modes when the uniform mode is unknown', () => {
const options = useGroupMenuOptions().getGroupModeOptions(
group({ nodes: [{ mode: 999 }] }),
vi.fn()
)
expect(options).toHaveLength(3)
})
})

View File

@@ -1,7 +1,6 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { downloadFile, openFileInNewTab } from '@/base/common/downloadUtil'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { useImageMenuOptions } from './useImageMenuOptions'
@@ -20,11 +19,6 @@ vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: vi.fn() })
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadFile: vi.fn(),
openFileInNewTab: vi.fn()
}))
function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
Object.defineProperty(navigator, 'clipboard', {
value: clipboard,
@@ -33,15 +27,6 @@ function mockClipboard(clipboard: Partial<Clipboard> | undefined) {
})
}
function stubClipboardItem() {
vi.stubGlobal(
'ClipboardItem',
class ClipboardItemStub {
constructor(public readonly items: Record<string, Blob>) {}
}
)
}
function createImageNode(
overrides: Partial<LGraphNode> | Record<string, unknown> = {}
): LGraphNode {
@@ -60,13 +45,8 @@ function createImageNode(
}
describe('useImageMenuOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('getImageMenuOptions', () => {
@@ -202,141 +182,4 @@ describe('useImageMenuOptions', () => {
expect(node.pasteFiles).not.toHaveBeenCalled()
})
})
describe('image actions', () => {
it('opens the selected image without preview query params', () => {
const node = createImageNode()
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
const { getImageMenuOptions } = useImageMenuOptions()
const openOption = getImageMenuOptions(node).find(
(o) => o.label === 'Open Image'
)
openOption?.action?.()
expect(openFileInNewTab).toHaveBeenCalledWith(
'http://localhost/test.png?foo=bar'
)
})
it('saves the selected image without preview query params', () => {
const node = createImageNode()
node.imgs![0].src = 'http://localhost/test.png?preview=1&foo=bar'
const { getImageMenuOptions } = useImageMenuOptions()
const saveOption = getImageMenuOptions(node).find(
(o) => o.label === 'Save Image'
)
saveOption?.action?.()
expect(downloadFile).toHaveBeenCalledWith(
'http://localhost/test.png?foo=bar'
)
})
it('does not open or save when the active image is missing', () => {
const node = createImageNode({ imageIndex: 1 })
const { getImageMenuOptions } = useImageMenuOptions()
const options = getImageMenuOptions(node)
options.find((o) => o.label === 'Open Image')?.action?.()
options.find((o) => o.label === 'Save Image')?.action?.()
expect(openFileInNewTab).not.toHaveBeenCalled()
expect(downloadFile).not.toHaveBeenCalled()
})
it('logs save failures for invalid image URLs', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = createImageNode()
Object.defineProperty(node.imgs![0], 'src', {
value: 'http://[',
configurable: true
})
const { getImageMenuOptions } = useImageMenuOptions()
getImageMenuOptions(node)
.find((o) => o.label === 'Save Image')
?.action?.()
expect(errorSpy).toHaveBeenCalledWith(
'Failed to save image:',
expect.any(TypeError)
)
expect(downloadFile).not.toHaveBeenCalled()
})
it('copies the selected image to clipboard', async () => {
const node = createImageNode()
const drawImage = vi.fn()
const write = vi.fn().mockResolvedValue(undefined)
stubClipboardItem()
mockClipboard(fromPartial<Clipboard>({ write }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['image'], { type: 'image/png' }))
}
)
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(drawImage).toHaveBeenCalledWith(node.imgs![0], 0, 0)
expect(write).toHaveBeenCalledWith([
expect.objectContaining({
items: { 'image/png': expect.any(Blob) }
})
])
})
it('does not copy when canvas context is unavailable', async () => {
const node = createImageNode()
const write = vi.fn()
mockClipboard(fromPartial<Clipboard>({ write }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
(() => null) as HTMLCanvasElement['getContext']
)
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(write).not.toHaveBeenCalled()
})
it('does not copy when canvas blob creation fails', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const node = createImageNode()
const write = vi.fn()
mockClipboard(fromPartial<Clipboard>({ write }))
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(
(() =>
fromPartial<CanvasRenderingContext2D>({
drawImage: vi.fn()
})) as unknown as HTMLCanvasElement['getContext']
)
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(null)
}
)
const { getImageMenuOptions } = useImageMenuOptions()
await getImageMenuOptions(node)
.find((o) => o.label === 'Copy Image')
?.action?.()
expect(warnSpy).toHaveBeenCalledWith('Failed to create image blob')
expect(write).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,292 +0,0 @@
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphGroup } from '@/lib/litegraph/src/litegraph'
import {
isNodeOptionsOpen,
registerNodeOptionsInstance,
showNodeOptions,
toggleNodeOptions,
useMoreOptionsMenu
} from '@/composables/graph/useMoreOptionsMenu'
const {
canvasState,
extraWidgetOptions,
imageOptions,
nodeMenu,
selectionMenu,
selectionState
} = vi.hoisted(() => ({
canvasState: {
canvas: undefined as
| undefined
| {
getNodeMenuOptions: ReturnType<typeof vi.fn>
}
},
extraWidgetOptions: {
value: [] as Array<{ content: string; callback?: () => void }>
},
imageOptions: {
value: [] as Array<{ label: string; hasSubmenu?: boolean; submenu?: [] }>
},
nodeMenu: {
visualOptions: {
value: [] as Array<{
label: string
hasSubmenu?: boolean
submenu?: Array<{ label: string; action: () => void }>
}>
}
},
selectionMenu: {
basicOptions: { value: [{ label: 'Copy' }] },
multipleOptions: { value: [{ label: 'Align' }] },
subgraphOptions: { value: [] as Array<{ label: string }> }
},
selectionState: {
selectedItems: { value: [] as unknown[] },
selectedNodes: { value: [] as unknown[] },
canOpenNodeInfo: { value: false },
openNodeInfo: vi.fn(() => true),
hasSubgraphs: { value: false },
hasImageNode: { value: false },
hasOutputNodesSelected: { value: false },
hasMultipleSelection: { value: false },
computeSelectionFlags: vi.fn(() => ({
collapsed: false,
pinned: false
}))
}
}))
vi.mock('@/composables/graph/useSelectionState', () => ({
useSelectionState: () => selectionState
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => canvasState
}))
vi.mock('@/services/litegraphService', () => ({
getExtraOptionsForWidget: () => extraWidgetOptions.value
}))
vi.mock('@/composables/graph/useImageMenuOptions', () => ({
useImageMenuOptions: () => ({
getImageMenuOptions: () => imageOptions.value
})
}))
vi.mock('@/composables/graph/useNodeMenuOptions', () => ({
useNodeMenuOptions: () => ({
getNodeInfoOption: (openNodeInfo: () => boolean) => ({
label: 'Node Info',
action: openNodeInfo
}),
getNodeVisualOptions: () => nodeMenu.visualOptions.value,
getPinOption: () => ({ label: 'Pin' }),
getBypassOption: () => ({ label: 'Bypass' }),
getRunBranchOption: () => ({ label: 'Run Branch' })
})
}))
vi.mock('@/composables/graph/useGroupMenuOptions', () => ({
useGroupMenuOptions: () => ({
getFitGroupToNodesOption: () => ({ label: 'Fit' }),
getGroupColorOptions: () => ({ label: 'Group Color' }),
getGroupModeOptions: () => [{ label: 'Group Mode' }]
})
}))
vi.mock('@/composables/graph/useSelectionMenuOptions', () => ({
useSelectionMenuOptions: () => ({
getBasicSelectionOptions: () => selectionMenu.basicOptions.value,
getMultipleNodesOptions: () => selectionMenu.multipleOptions.value,
getSubgraphOptions: () => selectionMenu.subgraphOptions.value
})
}))
beforeEach(() => {
vi.clearAllMocks()
registerNodeOptionsInstance(null)
canvasState.canvas = undefined
extraWidgetOptions.value = []
imageOptions.value = []
nodeMenu.visualOptions.value = []
selectionMenu.basicOptions.value = [{ label: 'Copy' }]
selectionMenu.multipleOptions.value = [{ label: 'Align' }]
selectionMenu.subgraphOptions.value = []
selectionState.selectedItems.value = []
selectionState.selectedNodes.value = []
selectionState.canOpenNodeInfo.value = false
selectionState.hasSubgraphs.value = false
selectionState.hasImageNode.value = false
selectionState.hasOutputNodesSelected.value = false
selectionState.hasMultipleSelection.value = false
selectionState.computeSelectionFlags.mockReturnValue({
collapsed: false,
pinned: false
})
})
function labels() {
return useMoreOptionsMenu()
.menuOptions.value.map((o) => o.label)
.filter(Boolean)
}
describe('node options popover instance', () => {
it('reports closed when no instance is registered', () => {
expect(isNodeOptionsOpen()).toBe(false)
})
it('reflects the registered instance open state and forwards toggle/show', () => {
const toggle = vi.fn()
const show = vi.fn()
registerNodeOptionsInstance({
toggle,
show,
hide: vi.fn(),
isOpen: ref(true)
})
expect(isNodeOptionsOpen()).toBe(true)
toggleNodeOptions(new Event('click'))
showNodeOptions(new MouseEvent('contextmenu'))
expect(toggle).toHaveBeenCalled()
expect(show).toHaveBeenCalled()
})
})
describe('useMoreOptionsMenu', () => {
it('assembles a non-empty menu for a single selected node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
expect(labels()).toContain('Copy')
expect(labels()).toContain('Pin')
})
it('includes run-branch and multiple-node options for output selections', () => {
const nodes = [
{ id: 1, widgets: [] },
{ id: 2, widgets: [] }
]
selectionState.selectedItems.value = nodes
selectionState.selectedNodes.value = nodes
selectionState.hasOutputNodesSelected.value = true
selectionState.hasMultipleSelection.value = true
const menuLabels = labels()
expect(menuLabels).toContain('Run Branch')
expect(menuLabels).toContain('Align')
})
it('recomputes menu flags after a manual bump', () => {
const { bump, menuOptions } = useMoreOptionsMenu()
void menuOptions.value
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(1)
bump()
void menuOptions.value
expect(selectionState.computeSelectionFlags).toHaveBeenCalledTimes(2)
})
it('assembles group-context options for a single selected group', () => {
const group = new LGraphGroup('Group')
selectionState.selectedItems.value = [group]
selectionState.selectedNodes.value = []
const menuLabels = labels()
expect(menuLabels).toContain('Group Mode')
expect(menuLabels).toContain('Fit')
expect(menuLabels).toContain('Group Color')
})
it('includes node info and visual options for a single node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
selectionState.canOpenNodeInfo.value = true
nodeMenu.visualOptions.value = [
{ label: 'Minimize Node' },
{ label: 'Shape', hasSubmenu: true, submenu: [] },
{ label: 'Color', hasSubmenu: true, submenu: [] }
]
const menu = useMoreOptionsMenu().menuOptions.value
expect(menu.map((o) => o.label)).toEqual(
expect.arrayContaining(['Node Info', 'Minimize Node', 'Shape', 'Color'])
)
menu.find((o) => o.label === 'Node Info')?.action?.()
expect(selectionState.openNodeInfo).toHaveBeenCalled()
})
it('returns only entries that have populated submenus', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
nodeMenu.visualOptions.value = [
{ label: 'Minimize Node' },
{
label: 'Shape',
hasSubmenu: true,
submenu: [{ label: 'Box', action: vi.fn() }]
},
{ label: 'Color', hasSubmenu: true }
]
expect(
useMoreOptionsMenu().menuOptionsWithSubmenu.value.map((o) => o.label)
).toEqual(['Shape'])
})
it('includes image menu options for a selected image node', () => {
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
selectionState.hasImageNode.value = true
imageOptions.value = [{ label: 'Open Image' }]
expect(labels()).toContain('Open Image')
})
it('merges LiteGraph menu options for a single selected node', () => {
const node = { id: 1, widgets: [] }
const getNodeMenuOptions = vi.fn(() => [
{ content: 'Extension Action', callback: vi.fn() }
])
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
canvasState.canvas = { getNodeMenuOptions }
expect(labels()).toContain('Extension Action')
expect(getNodeMenuOptions).toHaveBeenCalledWith(node)
})
it('keeps Vue options when LiteGraph menu construction throws', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const node = { id: 1, widgets: [] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
canvasState.canvas = {
getNodeMenuOptions: vi.fn(() => {
throw new Error('boom')
})
}
expect(labels()).toContain('Copy')
expect(errorSpy).toHaveBeenCalledWith(
'Error getting LiteGraph menu items:',
expect.any(Error)
)
})
it('adds hovered widget options to the selected node menu', () => {
const node = { id: 1, widgets: [{ name: 'image' }] }
selectionState.selectedItems.value = [node]
selectionState.selectedNodes.value = [node]
extraWidgetOptions.value = [{ content: 'Widget Extra', callback: vi.fn() }]
showNodeOptions(new MouseEvent('contextmenu'), 'image')
expect(labels()).toContain('Widget Extra')
})
})

View File

@@ -1,175 +0,0 @@
import type * as VueI18n from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeCustomization } from '@/composables/graph/useNodeCustomization'
const { selection, refreshCanvas, palette } = vi.hoisted(() => ({
selection: { items: [] as unknown[] },
refreshCanvas: vi.fn(),
palette: { light_theme: false }
}))
vi.mock('vue-i18n', async (importOriginal) => ({
...(await importOriginal<typeof VueI18n>()),
useI18n: () => ({ t: (key: string) => key })
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
get selectedItems() {
return selection.items
}
})
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
get completedActivePalette() {
return { light_theme: palette.light_theme }
}
})
}))
vi.mock('@/composables/graph/useCanvasRefresh', () => ({
useCanvasRefresh: () => ({ refreshCanvas })
}))
function colorable(bgcolor?: string) {
return {
setColorOption: vi.fn(),
getColorOption: () => (bgcolor ? { bgcolor } : null)
}
}
beforeEach(() => {
selection.items = []
refreshCanvas.mockReset()
palette.light_theme = false
})
describe('useNodeCustomization', () => {
it('exposes color and shape option lists', () => {
const { colorOptions, shapeOptions } = useNodeCustomization()
expect(colorOptions.length).toBeGreaterThan(1)
expect(shapeOptions.length).toBeGreaterThan(0)
})
it('reflects the active palette light-theme flag', () => {
palette.light_theme = true
expect(useNodeCustomization().isLightTheme.value).toBe(true)
})
it('clears color on all colorable items for the no-color option', () => {
const item = colorable()
selection.items = [item]
useNodeCustomization().applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('applies a named color option to colorable items', () => {
const item = colorable()
selection.items = [item]
const { colorOptions, applyColor } = useNodeCustomization()
const named = colorOptions.at(-1)!
applyColor(named)
expect(item.setColorOption).toHaveBeenCalledTimes(1)
expect(item.setColorOption.mock.calls[0][0]).not.toBeNull()
})
it('skips non-colorable items when applying colors', () => {
const item = colorable()
selection.items = [{}, item]
useNodeCustomization().applyColor(null)
expect(item.setColorOption).toHaveBeenCalledWith(null)
expect(refreshCanvas).toHaveBeenCalled()
})
it('returns null current color for an empty selection', () => {
expect(useNodeCustomization().getCurrentColor()).toBeNull()
})
it('returns null current color when no selected item is colorable', () => {
selection.items = [{}]
expect(useNodeCustomization().getCurrentColor()).toBeNull()
})
it('reports a recognized current color', () => {
const { colorOptions, getCurrentColor } = useNodeCustomization()
const named = colorOptions.at(-1)!
selection.items = [colorable(named.value.dark)]
expect(getCurrentColor()?.name).toBe(named.name)
})
it('falls back to the no-color option for an unrecognized current color', () => {
selection.items = [colorable('#not-a-known-color')]
const result = useNodeCustomization().getCurrentColor()
expect(result?.name).toBe('noColor')
})
it('no-ops shape changes when no graph nodes are selected', () => {
selection.items = [colorable()]
const { applyShape, shapeOptions } = useNodeCustomization()
applyShape(shapeOptions[0])
expect(refreshCanvas).not.toHaveBeenCalled()
})
it('returns null current shape with no nodes selected', () => {
expect(useNodeCustomization().getCurrentShape()).toBeNull()
})
it('applies a shape to selected graph nodes and refreshes', () => {
const node = new LGraphNode('Test')
selection.items = [node]
const { applyShape, shapeOptions } = useNodeCustomization()
const target = shapeOptions[0]
applyShape(target)
expect(node.shape).toBe(target.value)
expect(refreshCanvas).toHaveBeenCalled()
})
it('reports the current shape of a selected node', () => {
const node = new LGraphNode('Test')
const { shapeOptions, getCurrentShape } = useNodeCustomization()
node.shape = shapeOptions[0].value
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
it('uses the default shape when a selected node has no shape', () => {
const node = new LGraphNode('Test')
Object.defineProperty(node, 'shape', {
value: undefined,
writable: true,
configurable: true
})
const { shapeOptions, getCurrentShape } = useNodeCustomization()
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
it('falls back to the default shape for an unknown node shape', () => {
const node = new LGraphNode('Test')
Object.defineProperty(node, 'shape', {
value: 999,
writable: true,
configurable: true
})
const { shapeOptions, getCurrentShape } = useNodeCustomization()
selection.items = [node]
expect(getCurrentShape()?.value).toBe(shapeOptions[0].value)
})
})

View File

@@ -10,43 +10,30 @@ import { LGraphEventMode, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { toNodeId } from '@/types/nodeId'
const { actions, customization } = vi.hoisted(() => ({
actions: {
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
},
customization: {
shapeOptions: [] as Array<{ localizedName: string; value: string }>,
colorOptions: [] as Array<{
name: string
localizedName: string
value: { dark: string; light: string }
}>,
applyShape: vi.fn(),
applyColor: vi.fn(),
isLightTheme: { value: false }
}
}))
// canvasStore transitively imports the app singleton; stub it so the real
// ComfyApp module never loads during these unit tests.
vi.mock('@/scripts/app', () => ({
app: { canvas: { selected_nodes: null } }
}))
vi.mock('@/composables/graph/useNodeCustomization', () => ({
useNodeCustomization: () => ({
shapeOptions: customization.shapeOptions,
applyShape: customization.applyShape,
applyColor: customization.applyColor,
colorOptions: customization.colorOptions,
isLightTheme: customization.isLightTheme
shapeOptions: [],
applyShape: vi.fn(),
applyColor: vi.fn(),
colorOptions: [],
isLightTheme: { value: false }
})
}))
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
useSelectedNodeActions: () => actions
useSelectedNodeActions: () => ({
adjustNodeSize: vi.fn(),
toggleNodeCollapse: vi.fn(),
toggleNodePin: vi.fn(),
toggleNodeBypass: vi.fn(),
runBranch: vi.fn()
})
}))
const i18n = createI18n({
@@ -82,29 +69,9 @@ const getBypassLabel = (selected: LGraphNode[]): string => {
return label
}
function readNodeMenuOptions<T>(
read: (options: ReturnType<typeof useNodeMenuOptions>) => T
): T {
const unread = Symbol('unread')
const result: { value: T | typeof unread } = { value: unread }
const Wrapper = defineComponent({
setup() {
result.value = read(useNodeMenuOptions())
return () => null
}
})
render(Wrapper, { global: { plugins: [i18n] } })
if (result.value === unread) throw new Error('Composable was not read')
return result.value
}
describe('useNodeMenuOptions', () => {
describe('useNodeMenuOptions.getBypassOption', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
customization.shapeOptions = []
customization.colorOptions = []
customization.isLightTheme.value = false
})
it('labels as "Bypass" when no node is bypassed', () => {
@@ -130,109 +97,4 @@ describe('useNodeMenuOptions', () => {
])
).toBe('contextMenu.Bypass')
})
it('labels visual node options from the collapsed state and bumps after action', () => {
const expandBump = vi.fn()
const expand = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: true, pinned: false }, expandBump)[0]
)
expect(expand).toMatchObject({
label: 'contextMenu.Expand Node',
icon: 'icon-[lucide--maximize-2]'
})
expand.action?.()
expect(actions.toggleNodeCollapse).toHaveBeenCalledTimes(1)
expect(expandBump).toHaveBeenCalledTimes(1)
const minimize = readNodeMenuOptions(
({ getNodeVisualOptions }) =>
getNodeVisualOptions({ collapsed: false, pinned: false }, vi.fn())[0]
)
expect(minimize).toMatchObject({
label: 'contextMenu.Minimize Node',
icon: 'icon-[lucide--minimize-2]'
})
})
it('labels pin options from the pinned state and bumps after action', () => {
const bump = vi.fn()
const unpin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: true }, bump)
)
expect(unpin).toMatchObject({
label: 'contextMenu.Unpin',
icon: 'icon-[lucide--pin-off]'
})
unpin.action?.()
expect(actions.toggleNodePin).toHaveBeenCalledTimes(1)
expect(bump).toHaveBeenCalledTimes(1)
const pin = readNodeMenuOptions(({ getPinOption }) =>
getPinOption({ collapsed: false, pinned: false }, vi.fn())
)
expect(pin).toMatchObject({
label: 'contextMenu.Pin',
icon: 'icon-[lucide--pin]'
})
})
it('builds shape and color submenus and applies selected values', () => {
customization.shapeOptions = [{ localizedName: 'Box', value: 'box' }]
customization.colorOptions = [
{
name: 'noColor',
localizedName: 'No Color',
value: { dark: '#000', light: '#fff' }
},
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
const { visualOptions, colorSubmenu } = readNodeMenuOptions((options) => ({
visualOptions: options.getNodeVisualOptions(
{ collapsed: false, pinned: false },
vi.fn()
),
colorSubmenu: options.colorSubmenu.value
}))
expect(visualOptions[1].submenu).toEqual([
expect.objectContaining({ label: 'Box' })
])
visualOptions[1].submenu?.[0].action()
expect(customization.applyShape).toHaveBeenCalledWith(
customization.shapeOptions[0]
)
expect(colorSubmenu).toEqual([
expect.objectContaining({ label: 'No Color', color: '#000' }),
expect.objectContaining({ label: 'Red', color: '#111' })
])
colorSubmenu[0].action()
colorSubmenu[1].action()
expect(customization.applyColor).toHaveBeenNthCalledWith(1, null)
expect(customization.applyColor).toHaveBeenNthCalledWith(
2,
customization.colorOptions[1]
)
})
it('uses light-theme colors for the color submenu', () => {
customization.isLightTheme.value = true
customization.colorOptions = [
{
name: 'red',
localizedName: 'Red',
value: { dark: '#111', light: '#eee' }
}
]
expect(
readNodeMenuOptions((options) => options.colorSubmenu.value[0].color)
).toBe('#eee')
})
})

View File

@@ -1,221 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSelectionOperations } from '@/composables/graph/useSelectionOperations'
const {
canvas,
toastAdd,
captureCanvasState,
updateSelectedItems,
prompt,
titleEditor,
store
} = vi.hoisted(() => ({
canvas: {
selectedItems: new Set<unknown>(),
copyToClipboard: vi.fn(),
pasteFromClipboard: vi.fn(),
deleteSelected: vi.fn(),
setDirty: vi.fn()
},
toastAdd: vi.fn(),
captureCanvasState: vi.fn(),
updateSelectedItems: vi.fn(),
prompt: vi.fn(),
titleEditor: { titleEditorTarget: null as unknown },
store: { selectedItems: [] as unknown[] }
}))
vi.mock('@/scripts/app', () => ({ app: { canvas } }))
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: toastAdd })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: { changeTracker: { captureCanvasState } }
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
updateSelectedItems,
get selectedItems() {
return store.selectedItems
}
}),
useTitleEditorStore: () => titleEditor
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({ prompt })
}))
beforeEach(() => {
canvas.selectedItems = new Set()
canvas.copyToClipboard.mockReset()
canvas.pasteFromClipboard.mockReset()
canvas.deleteSelected.mockReset()
canvas.setDirty.mockReset()
toastAdd.mockReset()
captureCanvasState.mockReset()
updateSelectedItems.mockReset()
prompt.mockReset()
titleEditor.titleEditorTarget = null
store.selectedItems = []
})
describe('useSelectionOperations', () => {
it('warns and does nothing when copying an empty selection', () => {
useSelectionOperations().copySelection()
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('copies a non-empty selection and reports success', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().copySelection()
expect(canvas.copyToClipboard).toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
})
it('pastes from clipboard and captures canvas state', () => {
useSelectionOperations().pasteSelection()
expect(canvas.pasteFromClipboard).toHaveBeenCalledWith({
connectInputs: false
})
expect(captureCanvasState).toHaveBeenCalled()
})
it('duplicates by copy, clear, paste', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().duplicateSelection()
expect(canvas.copyToClipboard).toHaveBeenCalled()
expect(canvas.selectedItems.size).toBe(0)
expect(updateSelectedItems).toHaveBeenCalled()
expect(canvas.pasteFromClipboard).toHaveBeenCalled()
expect(captureCanvasState).toHaveBeenCalled()
})
it('warns when duplicating nothing', () => {
useSelectionOperations().duplicateSelection()
expect(canvas.copyToClipboard).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('deletes a non-empty selection and marks the canvas dirty', () => {
canvas.selectedItems = new Set(['a'])
useSelectionOperations().deleteSelection()
expect(canvas.deleteSelected).toHaveBeenCalled()
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('warns when deleting nothing', () => {
useSelectionOperations().deleteSelection()
expect(canvas.deleteSelected).not.toHaveBeenCalled()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
it('routes a single node rename to the title editor', async () => {
const node = new LGraphNode('Test')
store.selectedItems = [node]
await useSelectionOperations().renameSelection()
expect(titleEditor.titleEditorTarget).toBe(node)
expect(prompt).not.toHaveBeenCalled()
})
it('renames a single non-node item via the prompt dialog', async () => {
const group = { title: 'Old' }
store.selectedItems = [group]
prompt.mockResolvedValue('New')
await useSelectionOperations().renameSelection()
expect(group.title).toBe('New')
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('leaves a single titled item unchanged when the prompt returns the same title', async () => {
const group = { title: 'Old' }
store.selectedItems = [group]
prompt.mockResolvedValue('Old')
await useSelectionOperations().renameSelection()
expect(group.title).toBe('Old')
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('does not assign a title to a selected item without a title property', async () => {
const item = {}
store.selectedItems = [item]
prompt.mockResolvedValue('New')
await useSelectionOperations().renameSelection()
expect(item).toEqual({})
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('batch-renames multiple items with an indexed base name', async () => {
const a = { title: 'a' }
const b = { title: 'b' }
store.selectedItems = [a, b]
prompt.mockResolvedValue('Item')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('Item 1')
expect(b.title).toBe('Item 2')
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('skips untitled items during batch rename', async () => {
const a = { title: 'a' }
const b = {}
store.selectedItems = [a, b]
prompt.mockResolvedValue('Item')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('Item 1')
expect(b).toEqual({})
expect(canvas.setDirty).toHaveBeenCalledWith(true, true)
expect(captureCanvasState).toHaveBeenCalled()
})
it('leaves a multiple selection unchanged when batch rename is cancelled', async () => {
const a = { title: 'a' }
const b = { title: 'b' }
store.selectedItems = [a, b]
prompt.mockResolvedValue('')
await useSelectionOperations().renameSelection()
expect(a.title).toBe('a')
expect(b.title).toBe('b')
expect(canvas.setDirty).not.toHaveBeenCalled()
expect(captureCanvasState).not.toHaveBeenCalled()
})
it('warns when renaming an empty selection', async () => {
await useSelectionOperations().renameSelection()
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
})
})

View File

@@ -8,12 +8,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import {
isImageNode,
isLGraphGroup,
isLGraphNode,
isLoad3dNode
} from '@/utils/litegraphUtil'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
import {
createMockLGraphNode,
@@ -22,9 +17,7 @@ import {
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(),
isImageNode: vi.fn(),
isLGraphGroup: vi.fn(),
isLoad3dNode: vi.fn()
isImageNode: vi.fn()
}))
vi.mock('@/utils/nodeFilterUtil', () => ({
@@ -103,14 +96,6 @@ describe('useSelectionState', () => {
const typedNode = node as { type?: string }
return typedNode?.type === 'ImageNode'
})
vi.mocked(isLGraphGroup).mockImplementation((item: unknown) => {
const typedItem = item as { isGroup?: boolean }
return typedItem?.isGroup === true
})
vi.mocked(isLoad3dNode).mockImplementation((node: unknown) => {
const typedNode = node as { type?: string }
return typedNode?.type === 'Load3D'
})
vi.mocked(filterOutputNodes).mockImplementation((nodes) =>
nodes.filter((n) => n.type === 'OutputNode')
)
@@ -150,21 +135,6 @@ describe('useSelectionState', () => {
const { hasMultipleSelection } = useSelectionState()
expect(hasMultipleSelection.value).toBe(false)
})
test('hasGroupedNodesSelection should detect a group containing nodes', () => {
const canvasStore = useCanvasStore()
const graphNode = createMockLGraphNode({ id: 2 })
const group = createMockPositionable({ id: 2000 })
Object.assign(group, {
isGroup: true,
isNode: false,
children: new Set([graphNode])
})
canvasStore.$state.selectedItems = [group]
const { hasGroupedNodesSelection } = useSelectionState()
expect(hasGroupedNodesSelection.value).toBe(true)
})
})
describe('Node Type Filtering', () => {
@@ -245,13 +215,6 @@ describe('useSelectionState', () => {
const newIsPinned = newSelectedNodes.value.some((n) => n.pinned === true)
expect(newIsPinned).toBe(false)
})
test('should compute default flags for an empty node selection', () => {
expect(useSelectionState().computeSelectionFlags()).toEqual({
collapsed: false,
pinned: false
})
})
})
describe('Node Info', () => {

View File

@@ -1,315 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, defineComponent, h, nextTick } from 'vue'
import type { App as VueApp } from 'vue'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { BadgePosition, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import type { ComfyExtension } from '@/types/comfy'
import { toNodeId } from '@/types/nodeId'
import { NodeBadgeMode } from '@/types/nodeSource'
const {
settings,
appState,
extensionState,
nodeDefState,
pricingState,
setDirtyMock,
addEventListenerMock,
registerExtensionMock,
getCreditsBadgeMock,
updateSubgraphCreditsMock,
getNodePricingConfigMock,
getNodeDisplayPriceMock,
getRelevantWidgetNamesMock,
triggerPriceRecalculationMock,
useComputedWithWidgetWatchMock
} = vi.hoisted(() => ({
settings: {} as Record<string, unknown>,
appState: {
graph: {
nodes: [] as unknown[]
}
},
extensionState: {
installed: false,
registered: undefined as ComfyExtension | undefined
},
nodeDefState: {
value: null as Record<string, unknown> | null
},
pricingState: {
revision: { value: 0 },
config: undefined as
| {
depends_on?: {
widgets?: string[]
inputs?: string[]
input_groups?: string[]
}
}
| undefined,
label: '1 credit'
},
setDirtyMock: vi.fn(),
addEventListenerMock: vi.fn(),
registerExtensionMock: vi.fn((extension: ComfyExtension) => {
extensionState.registered = extension
}),
getCreditsBadgeMock: vi.fn((text: string) => ({ text })),
updateSubgraphCreditsMock: vi.fn(),
getNodePricingConfigMock: vi.fn(() => pricingState.config),
getNodeDisplayPriceMock: vi.fn(() => pricingState.label),
getRelevantWidgetNamesMock: vi.fn(() => ['seed']),
triggerPriceRecalculationMock: vi.fn(),
useComputedWithWidgetWatchMock: vi.fn(() => vi.fn())
}))
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
setDirty: setDirtyMock,
canvas: {
addEventListener: addEventListenerMock
},
graph: appState.graph
}
}
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => settings[key]
})
}))
vi.mock('@/stores/extensionStore', () => ({
useExtensionStore: () => ({
isExtensionInstalled: () => extensionState.installed,
registerExtension: registerExtensionMock
})
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
fromLGraphNode: () => nodeDefState.value
})
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: () => ({
completedActivePalette: {
colors: {
litegraph_base: {
BADGE_FG_COLOR: '#fff',
BADGE_BG_COLOR: '#000'
}
}
}
})
}))
vi.mock('@/composables/node/useNodePricing', () => ({
useNodePricing: () => ({
pricingRevision: pricingState.revision,
getNodePricingConfig: getNodePricingConfigMock,
getNodeDisplayPrice: getNodeDisplayPriceMock,
getRelevantWidgetNames: getRelevantWidgetNamesMock,
triggerPriceRecalculation: triggerPriceRecalculationMock
})
}))
vi.mock('@/composables/node/usePriceBadge', () => ({
usePriceBadge: () => ({
getCreditsBadge: getCreditsBadgeMock,
updateSubgraphCredits: updateSubgraphCreditsMock
})
}))
vi.mock('@/composables/node/useWatchWidget', () => ({
useComputedWithWidgetWatch: useComputedWithWidgetWatchMock
}))
class ApiNode extends LGraphNode {
static override nodeData = { name: 'ApiNode', api_node: true }
}
function mountBadge(): VueApp {
const app = createApp(
defineComponent({
setup() {
useNodeBadge()
return () => h('div')
}
})
)
app.mount(document.createElement('div'))
return app
}
function registeredExtension(): ComfyExtension {
if (!extensionState.registered)
throw new Error('Missing registered extension')
return extensionState.registered
}
function comfyApp(): Parameters<NonNullable<ComfyExtension['init']>>[0] {
return {} as Parameters<NonNullable<ComfyExtension['init']>>[0]
}
function callNodeCreated(node: LGraphNode) {
registeredExtension().nodeCreated?.(node, comfyApp())
}
function inputSlot(name: string) {
return new LGraphNode('slot').addInput(name, '*')
}
function defaultSettings() {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] = NodeBadgeMode.None
settings['Comfy.NodeBadge.ShowApiPricing'] = false
}
describe('useNodeBadge', () => {
let mountedApp: VueApp | undefined
beforeEach(() => {
defaultSettings()
extensionState.installed = false
extensionState.registered = undefined
appState.graph.nodes = []
nodeDefState.value = null
pricingState.revision.value = 0
pricingState.config = undefined
pricingState.label = '1 credit'
setDirtyMock.mockClear()
addEventListenerMock.mockClear()
registerExtensionMock.mockClear()
getCreditsBadgeMock.mockClear()
updateSubgraphCreditsMock.mockClear()
getNodePricingConfigMock.mockClear()
getNodeDisplayPriceMock.mockClear()
getRelevantWidgetNamesMock.mockClear()
triggerPriceRecalculationMock.mockClear()
useComputedWithWidgetWatchMock.mockClear()
})
afterEach(() => {
mountedApp?.unmount()
mountedApp = undefined
})
it('does not register the badge extension twice', async () => {
extensionState.installed = true
mountedApp = mountBadge()
await nextTick()
expect(registerExtensionMock).not.toHaveBeenCalled()
})
it('adds the configured node identity badge', async () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
NodeBadgeMode.HideBuiltIn
nodeDefState.value = {
isCoreNode: false,
nodeLifeCycleBadgeText: 'Beta',
nodeSource: { badgeText: 'Pack' }
}
const node = new LGraphNode('Test')
node.id = toNodeId('7')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
const badge = node.badges[0] as () => LGraphBadge
expect(node.badgePosition).toBe(BadgePosition.TopRight)
expect(badge().text).toBe('#7 Beta Pack')
})
it('hides built-in badge text when the mode excludes core nodes', async () => {
settings['Comfy.NodeBadge.NodeSourceBadgeMode'] = NodeBadgeMode.HideBuiltIn
settings['Comfy.NodeBadge.NodeIdBadgeMode'] = NodeBadgeMode.ShowAll
settings['Comfy.NodeBadge.NodeLifeCycleBadgeMode'] =
NodeBadgeMode.HideBuiltIn
nodeDefState.value = {
isCoreNode: true,
nodeLifeCycleBadgeText: 'Core',
nodeSource: { badgeText: 'Built-in' }
}
const node = new LGraphNode('Core')
node.id = toNodeId('11')
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
const badge = node.badges[0] as () => LGraphBadge
expect(badge().text).toBe('#11')
})
it('adds dynamic API pricing badges and refreshes relevant input changes', async () => {
settings['Comfy.NodeBadge.ShowApiPricing'] = true
pricingState.config = {
depends_on: {
widgets: ['seed'],
inputs: ['image'],
input_groups: ['lora']
}
}
const originalOnConnectionsChange = vi.fn()
const node = new ApiNode('API')
node.onConnectionsChange = originalOnConnectionsChange
mountedApp = mountBadge()
await nextTick()
callNodeCreated(node)
expect(useComputedWithWidgetWatchMock).toHaveBeenCalledWith(node, {
widgetNames: ['seed'],
triggerCanvasRedraw: true
})
expect(getCreditsBadgeMock).toHaveBeenCalledWith('1 credit')
const priceBadge = node.badges[1] as () => { text: string }
expect(priceBadge().text).toBe('1 credit')
pricingState.label = '2 credits'
expect(priceBadge().text).toBe('2 credits')
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('image'))
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('lora.0'))
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot('clip'))
node.onConnectionsChange?.(1, 0, true, undefined, inputSlot(''))
expect(originalOnConnectionsChange).toHaveBeenCalledTimes(4)
expect(triggerPriceRecalculationMock).toHaveBeenCalledTimes(2)
expect(triggerPriceRecalculationMock).toHaveBeenCalledWith(node)
})
it('updates subgraph credit badges from registered extension hooks', async () => {
const nodes = [new LGraphNode('one'), new LGraphNode('two')]
appState.graph.nodes = nodes
mountedApp = mountBadge()
await nextTick()
await registeredExtension().init?.(comfyApp())
await registeredExtension().afterConfigureGraph?.([], comfyApp())
const setGraphHandler = addEventListenerMock.mock.calls.find(
([event]) => event === 'litegraph:set-graph'
)?.[1]
const convertedHandler = addEventListenerMock.mock.calls.find(
([event]) => event === 'subgraph-converted'
)?.[1]
setGraphHandler?.()
convertedHandler?.({ detail: { subgraphNode: nodes[0] } })
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[0])
expect(updateSubgraphCreditsMock).toHaveBeenCalledWith(nodes[1])
})
})

View File

@@ -1,6 +1,4 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import { CREDITS_PER_USD, formatCredits } from '@/base/credits/comfyCredits'
import {
@@ -14,7 +12,6 @@ import {
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDef, PriceBadge } from '@/schemas/nodeDefSchema'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { toNodeId } from '@/types/nodeId'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
@@ -126,35 +123,6 @@ function createMockNode(
})
}
async function resolveDisplayPrice(
node: LGraphNode,
widgetOverrides?: ReadonlyMap<string, unknown>
): Promise<string> {
const { getNodeDisplayPrice } = useNodePricing()
getNodeDisplayPrice(node, widgetOverrides)
await new Promise((resolve) => setTimeout(resolve, 50))
return getNodeDisplayPrice(node, widgetOverrides)
}
function createStoredNodeDef(
name: string,
price_badge?: PriceBadge
): ComfyNodeDef {
return {
name,
display_name: name,
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'test',
price_badge
} as ComfyNodeDef
}
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
@@ -221,32 +189,6 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.5))
})
it('should parse numeric strings and reject blank or invalid numbers', async () => {
const expression =
'{"type":"usd","usd": (widgets.count != null) ? widgets.count * 0.01 : 0.20}'
const badge = priceBadge(expression, [{ name: 'count', type: 'INT' }])
const parsedNode = createMockNodeWithPriceBadge(
'TestNumericStringNode',
badge,
[{ name: 'count', value: ' 5 ' }]
)
const blankNode = createMockNodeWithPriceBadge(
'TestBlankNumericStringNode',
badge,
[{ name: 'count', value: ' ' }]
)
const invalidNode = createMockNodeWithPriceBadge(
'TestInvalidNumericStringNode',
badge,
[{ name: 'count', value: 'five' }]
)
expect(await resolveDisplayPrice(parsedNode)).toBe(creditsLabel(0.05))
expect(await resolveDisplayPrice(blankNode)).toBe(creditsLabel(0.2))
expect(await resolveDisplayPrice(invalidNode)).toBe(creditsLabel(0.2))
})
it('should handle COMBO widget with numeric value', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -280,19 +222,6 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.1))
})
it('should preserve boolean combo values', async () => {
const node = createMockNodeWithPriceBadge(
'TestComboBooleanNode',
priceBadge(
'(widgets.enabled = false) ? {"type":"usd","usd":0.04} : {"type":"usd","usd":0.08}',
[{ name: 'enabled', type: 'COMBO' }]
),
[{ name: 'enabled', value: false }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.04))
})
it('should handle BOOLEAN widget', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -309,51 +238,6 @@ describe('useNodePricing', () => {
expect(price).toBe(creditsLabel(0.1))
})
it('should parse BOOLEAN widget string values', async () => {
const badge = priceBadge(
'{"type":"usd","usd": widgets.premium ? 0.10 : 0.05}',
[{ name: 'premium', type: 'BOOLEAN' }]
)
const enabledNode = createMockNodeWithPriceBadge(
'TestBooleanStringTrueNode',
badge,
[{ name: 'premium', value: ' TRUE ' }]
)
const disabledNode = createMockNodeWithPriceBadge(
'TestBooleanStringFalseNode',
badge,
[{ name: 'premium', value: 'false' }]
)
expect(await resolveDisplayPrice(enabledNode)).toBe(creditsLabel(0.1))
expect(await resolveDisplayPrice(disabledNode)).toBe(creditsLabel(0.05))
})
it('should reject invalid BOOLEAN strings', async () => {
const node = createMockNodeWithPriceBadge(
'TestInvalidBooleanStringNode',
priceBadge(
'{"type":"usd","usd": widgets.premium = null ? 0.05 : 0.10}',
[{ name: 'premium', type: 'BOOLEAN' }]
),
[{ name: 'premium', value: 'sometimes' }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should reject object values for numeric widgets', async () => {
const node = createMockNodeWithPriceBadge(
'TestObjectNumericNode',
priceBadge('{"type":"usd","usd": widgets.count = null ? 0.05 : 0.10}', [
{ name: 'count', type: 'INT' }
]),
[{ name: 'count', value: { count: 5 } }]
)
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
it('should handle STRING widget (lowercased)', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -584,42 +468,6 @@ describe('useNodePricing', () => {
})
})
describe('dependency context', () => {
it('should prefer widget overrides over node widget values', async () => {
const node = createMockNodeWithPriceBadge(
'TestWidgetOverrideNode',
priceBadge('{"type":"usd","usd": widgets.count * 0.01}', [
{ name: 'count', type: 'INT' }
]),
[{ name: 'count', value: 2 }]
)
const price = await resolveDisplayPrice(node, new Map([['count', '7']]))
expect(price).toBe(creditsLabel(0.07))
})
it('should treat missing input group arrays as zero connected inputs', async () => {
const node = Object.assign(createMockLGraphNode(), {
widgets: [],
constructor: {
nodeData: {
name: 'TestMissingInputGroupArrayNode',
api_node: true,
price_badge: priceBadge(
'{"type":"usd","usd": (inputGroups.images = 0) ? 0.05 : 0.10}',
[],
[],
['images']
)
}
}
})
expect(await resolveDisplayPrice(node)).toBe(creditsLabel(0.05))
})
})
describe('edge cases', () => {
it('should return empty string for non-API nodes', () => {
const { getNodeDisplayPrice } = useNodePricing()
@@ -747,86 +595,6 @@ describe('useNodePricing', () => {
})
})
describe('node type pricing dependencies', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns empty dependency metadata for node types without pricing', () => {
const store = useNodeDefStore()
store.addNodeDef(createStoredNodeDef('UnpricedNode'))
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('UnpricedNode')).toEqual([])
expect(hasDynamicPricing('UnpricedNode')).toBe(false)
expect(getInputGroupPrefixes('UnpricedNode')).toEqual([])
expect(getInputNames('UnpricedNode')).toEqual([])
})
it('dedupes dynamic pricing dependencies while preserving order', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef(
'DynamicPricingNode',
priceBadge(
'{"type":"usd","usd":0.05}',
[
{ name: 'seed', type: 'INT' },
{ name: 'quality', type: 'COMBO' }
],
['image', 'seed'],
['clips', 'image']
)
)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('DynamicPricingNode')).toEqual([
'seed',
'quality',
'image',
'clips'
])
expect(hasDynamicPricing('DynamicPricingNode')).toBe(true)
expect(getInputGroupPrefixes('DynamicPricingNode')).toEqual([
'clips',
'image'
])
expect(getInputNames('DynamicPricingNode')).toEqual(['image', 'seed'])
})
it('handles fixed pricing metadata without dependencies', () => {
const store = useNodeDefStore()
store.addNodeDef(
createStoredNodeDef(
'FixedPricingNode',
priceBadge('{"type":"usd","usd":0.05}')
)
)
const {
getInputGroupPrefixes,
getInputNames,
getRelevantWidgetNames,
hasDynamicPricing
} = useNodePricing()
expect(getRelevantWidgetNames('FixedPricingNode')).toEqual([])
expect(hasDynamicPricing('FixedPricingNode')).toBe(false)
expect(getInputGroupPrefixes('FixedPricingNode')).toEqual([])
expect(getInputNames('FixedPricingNode')).toEqual([])
})
})
describe('reactive revision', () => {
it('bumps pricingRevision after an async evaluation resolves (Nodes 1.0 mode)', async () => {
const { getNodeDisplayPrice, pricingRevision } = useNodePricing()
@@ -975,16 +743,6 @@ describe('useNodePricing', () => {
expect(price).toBe('')
})
it('should reuse the cached empty label after runtime failures', async () => {
const node = createMockNodeWithPriceBadge(
'TestCachedRuntimeErrorNode',
priceBadge('$lookup(undefined, "key")')
)
expect(await resolveDisplayPrice(node)).toBe('')
expect(await resolveDisplayPrice(node)).toBe('')
})
it('should return empty string for invalid PricingResult type', async () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNodeWithPriceBadge(
@@ -1210,21 +968,8 @@ describe('formatPricingResult', () => {
expect(result).toBe('~10.6')
})
it('should parse string usd values with default approximate formatting', () => {
const result = formatPricingResult(
{ type: 'usd', usd: '0.05' },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6')
})
it('should return empty for null usd', () => {
const result = formatPricingResult({ type: 'usd', usd: null })
expect(result).toBe('')
})
it('should return empty for blank string usd', () => {
const result = formatPricingResult({ type: 'usd', usd: ' ' })
const result = formatPricingResult({ type: 'usd', usd: null as never })
expect(result).toBe('')
})
})
@@ -1254,14 +999,6 @@ describe('formatPricingResult', () => {
)
expect(result).toBe('10.6')
})
it('should parse string range values with default approximate formatting', () => {
const result = formatPricingResult(
{ type: 'range_usd', min_usd: '0.05', max_usd: '0.1' },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6-21.1')
})
})
describe('type: list_usd', () => {
@@ -1280,22 +1017,6 @@ describe('formatPricingResult', () => {
)
expect(result).toBe('10.6/21.1')
})
it('should return valueOnly format with approximate prefix', () => {
const result = formatPricingResult(
{ type: 'list_usd', usd: [0.05, 0.1] },
{ valueOnly: true, defaults: { approximate: true } }
)
expect(result).toBe('~10.6/21.1')
})
it('should return empty when list value is not an array', () => {
const result = formatPricingResult({
type: 'list_usd',
usd: 'not-a-list'
})
expect(result).toBe('')
})
})
describe('type: text', () => {
@@ -1303,11 +1024,6 @@ describe('formatPricingResult', () => {
const result = formatPricingResult({ type: 'text', text: 'Free' })
expect(result).toBe('Free')
})
it('should return empty when text is missing', () => {
const result = formatPricingResult({ type: 'text' })
expect(result).toBe('')
})
})
describe('legacy format', () => {
@@ -1474,29 +1190,6 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('21.1') // 10 * 0.01 = 0.1 USD = 21.1 credits
})
it('should use default value from optional input spec', async () => {
const nodeDef = createMockNodeDef({
name: 'OptionalDefaultValueNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.count * 0.01}',
depends_on: {
widgets: [{ name: 'count', type: 'INT' }],
inputs: [],
input_groups: []
}
},
input: {
required: {},
optional: {
count: ['INT', { default: 4 }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('8.4')
})
it('should use first option for COMBO without default', async () => {
const nodeDef = createMockNodeDef({
name: 'ComboNode',
@@ -1572,30 +1265,6 @@ describe('evaluateNodeDefPricing', () => {
expect(result).toBe('10.6')
})
it('should handle combo option arrays with primitive values', async () => {
const nodeDef = createMockNodeDef({
name: 'PrimitiveOptionsNode',
price_badge: {
engine: 'jsonata',
expr: '{"type":"usd","usd": widgets.mode = "fast" ? 0.05 : 0.10}',
depends_on: {
widgets: [{ name: 'mode', type: 'COMBO' }],
inputs: [],
input_groups: []
}
},
input: {
required: {
mode: ['COMBO', { options: ['fast', 'slow'] }]
}
}
})
const result = await evaluateNodeDefPricing(nodeDef)
expect(result).toBe('10.6')
})
it('should assume inputs disconnected in preview', async () => {
const nodeDef = createMockNodeDef({
name: 'InputConnectedNode',

View File

@@ -12,12 +12,11 @@ export function resolveEssentialTileNodeDef(
): ComfyNodeDefImpl | undefined {
const name = tile.nodeName
if (!name) return undefined
const byName = nodeDefStore.allNodeDefsByName[name]
if (byName) return byName
const target = name.startsWith(BLUEPRINT_TYPE_PREFIX)
? name.slice(BLUEPRINT_TYPE_PREFIX.length)
: name
return nodeDefStore.nodeDefs.find((d) => d.display_name === target)
if (!name.startsWith(BLUEPRINT_TYPE_PREFIX))
return nodeDefStore.allNodeDefsByName[name]
const subgraphName = name.slice(BLUEPRINT_TYPE_PREFIX.length)
return nodeDefStore.allNodeDefsByDisplayName[subgraphName]
}
export function useEssentialTileNodeDef(tile: MaybeRefOrGetter<EssentialTile>) {

View File

@@ -50,6 +50,7 @@ interface HarnessOptions {
valueMax?: number
showMidpoint?: boolean
track?: HTMLElement | null
contentInsetX?: number
}
interface Harness {
@@ -72,6 +73,7 @@ const mountRangeEditor = (opts: HarnessOptions = {}): Harness => {
const valueMin = ref(opts.valueMin ?? 0)
const valueMax = ref(opts.valueMax ?? 100)
const showMidpoint = ref(opts.showMidpoint ?? true)
const contentInsetX = ref(opts.contentInsetX ?? 0)
let api: ReturnType<typeof useRangeEditor> | undefined
const TestComponent = defineComponent({
@@ -81,7 +83,8 @@ const mountRangeEditor = (opts: HarnessOptions = {}): Harness => {
modelValue,
valueMin,
valueMax,
showMidpoint
showMidpoint,
contentInsetX
})
return () => null
}
@@ -323,4 +326,44 @@ describe('useRangeEditor', () => {
expect.arrayContaining(['pointermove', 'pointerup', 'lostpointercapture'])
)
})
it('maps pointer at content inset to valueMin when contentInsetX is set', () => {
harness = mountRangeEditor({
initial: { min: 20, max: 80, midpoint: 0.5 },
valueMin: 0,
valueMax: 100,
showMidpoint: false,
contentInsetX: 16
})
harness.api.startDrag(
'min',
createPointerEvent('pointerdown', { clientX: 16 })
)
harness.trackRef.value!.dispatchEvent(
createPointerEvent('pointermove', { clientX: 16 })
)
expect(harness.modelValue.value.min).toBe(0)
})
it('maps pointer at right content inset to valueMax when contentInsetX is set', () => {
harness = mountRangeEditor({
initial: { min: 0, max: 80, midpoint: 0.5 },
valueMin: 0,
valueMax: 100,
showMidpoint: false,
contentInsetX: 16
})
harness.api.startDrag(
'max',
createPointerEvent('pointerdown', { clientX: 184 })
)
harness.trackRef.value!.dispatchEvent(
createPointerEvent('pointermove', { clientX: 184 })
)
expect(harness.modelValue.value.max).toBe(100)
})
})

View File

@@ -14,6 +14,7 @@ interface UseRangeEditorOptions {
valueMin: Ref<number>
valueMax: Ref<number>
showMidpoint: Ref<boolean>
contentInsetX?: Ref<number>
}
export function useRangeEditor({
@@ -21,7 +22,8 @@ export function useRangeEditor({
modelValue,
valueMin,
valueMax,
showMidpoint
showMidpoint,
contentInsetX
}: UseRangeEditorOptions) {
const activeHandle = ref<HandleType | null>(null)
let cleanupDrag: (() => void) | null = null
@@ -30,7 +32,13 @@ export function useRangeEditor({
const el = trackRef.value
if (!el) return valueMin.value
const rect = el.getBoundingClientRect()
const normalized = clamp((e.clientX - rect.left) / rect.width, 0, 1)
const inset = contentInsetX?.value ?? 0
const contentWidth = Math.max(rect.width - 2 * inset, 1)
const normalized = clamp(
(e.clientX - rect.left - inset) / contentWidth,
0,
1
)
return denormalize(normalized, valueMin.value, valueMax.value)
}
@@ -108,6 +116,7 @@ export function useRangeEditor({
return {
handleTrackPointerDown,
startDrag
startDrag,
activeHandle
}
}

View File

@@ -1,102 +0,0 @@
import { ref } from 'vue'
import { describe, expect, it } from 'vitest'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import type { TreeNode } from '@/types/treeExplorerTypes'
function node(over: Partial<TreeNode>): TreeNode {
return over as TreeNode
}
// root ─┬─ a ── a1 (leaf)
// └─ b (leaf)
function sampleTree() {
const a1 = node({ key: 'a1', leaf: true })
const a = node({ key: 'a', leaf: false, children: [a1] })
const b = node({ key: 'b', leaf: true })
const root = node({ key: 'root', leaf: false, children: [a, b] })
return { root, a, a1, b }
}
describe('useTreeExpansion', () => {
it('toggleNode adds then removes a node key', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNode } = useTreeExpansion(expandedKeys)
const n = node({ key: 'x' })
toggleNode(n)
expect(expandedKeys.value).toEqual({ x: true })
toggleNode(n)
expect(expandedKeys.value).toEqual({})
})
it('toggleNode ignores nodes without a string key', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNode } = useTreeExpansion(expandedKeys)
toggleNode(node({ key: undefined }))
toggleNode(node({ key: 42 as unknown as string }))
expect(expandedKeys.value).toEqual({})
})
it('expandNode expands the node and all non-leaf descendants only', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
expandNode(root)
// root and a are folders; a1 and b are leaves and must be skipped
expect(expandedKeys.value).toEqual({ root: true, a: true })
})
it('expandNode does nothing for a leaf node', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode } = useTreeExpansion(expandedKeys)
expandNode(node({ key: 'leaf', leaf: true }))
expect(expandedKeys.value).toEqual({})
})
it('collapseNode removes the node and its non-leaf descendants', () => {
const expandedKeys = ref<Record<string, boolean>>({
root: true,
a: true,
stray: true
})
const { collapseNode } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
collapseNode(root)
expect(expandedKeys.value).toEqual({ stray: true })
})
it('toggleNodeRecursive expands when collapsed and collapses when expanded', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNodeRecursive } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
toggleNodeRecursive(root)
expect(expandedKeys.value).toEqual({ root: true, a: true })
toggleNodeRecursive(root)
expect(expandedKeys.value).toEqual({})
})
it('toggleNodeOnEvent toggles recursively with ctrl and singly without', () => {
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const { root } = sampleTree()
toggleNodeOnEvent(new KeyboardEvent('keydown', { ctrlKey: true }), root)
expect(expandedKeys.value).toEqual({ root: true, a: true })
// Plain toggle removes only the node's own key, leaving descendants
toggleNodeOnEvent(new MouseEvent('click'), root)
expect(expandedKeys.value).toEqual({ a: true })
})
})

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { parseMp4AverageFrameRate } from './probeVideoFrameRate'
function writeUint32(value: number): Uint8Array {
const bytes = new Uint8Array(4)
new DataView(bytes.buffer).setUint32(0, value)
return bytes
}
function writeBox(type: string, content: Uint8Array): Uint8Array {
const box = new Uint8Array(8 + content.length)
box.set(writeUint32(8 + content.length), 0)
for (let index = 0; index < 4; index++) {
box[4 + index] = type.charCodeAt(index)
}
box.set(content, 8)
return box
}
function concatBoxes(...boxes: Uint8Array[]): Uint8Array {
const totalLength = boxes.reduce((sum, box) => sum + box.length, 0)
const merged = new Uint8Array(totalLength)
let offset = 0
for (const box of boxes) {
merged.set(box, offset)
offset += box.length
}
return merged
}
function createVideoTrackBox(
sampleCount: number,
timescale: number
): Uint8Array {
const handler = writeBox(
'hdlr',
concatBoxes(
writeUint32(0),
writeUint32(0),
new Uint8Array([0x76, 0x69, 0x64, 0x65])
)
)
const mediaHeader = writeBox(
'mdhd',
concatBoxes(
writeUint32(0),
writeUint32(0),
writeUint32(0),
writeUint32(timescale),
writeUint32(timescale * 10)
)
)
const sampleSizes = writeBox(
'stsz',
concatBoxes(writeUint32(0), writeUint32(0), writeUint32(sampleCount))
)
const media = writeBox('mdia', concatBoxes(mediaHeader, sampleSizes, handler))
return writeBox('trak', concatBoxes(media))
}
describe('parseMp4AverageFrameRate', () => {
it('derives average frame rate from video track sample count and duration', () => {
const moov = writeBox('moov', createVideoTrackBox(240, 24))
const data = concatBoxes(moov)
expect(parseMp4AverageFrameRate(data, 10)).toBe(24)
})
it('returns undefined when moov metadata is missing', () => {
expect(parseMp4AverageFrameRate(new Uint8Array([0, 0, 0, 0]), 10)).toBe(
undefined
)
})
})

View File

@@ -0,0 +1,198 @@
import { fetchHttpResourceByteSize } from '@/utils/httpResourceByteSize'
const PROBE_CHUNK_BYTES = 512 * 1024
const MAX_FRAME_RATE = 240
interface BoxRange {
type: string
start: number
end: number
}
function readUint32(data: Uint8Array, offset: number): number {
if (offset + 4 > data.length) return 0
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
return view.getUint32(offset)
}
function readBoxType(data: Uint8Array, offset: number): string {
if (offset + 4 > data.length) return ''
return String.fromCharCode(
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3]
)
}
function* iterateBoxes(
data: Uint8Array,
start: number,
end: number
): Generator<BoxRange> {
let pos = start
while (pos + 8 <= end) {
let size = readUint32(data, pos)
const type = readBoxType(data, pos + 4)
let headerSize = 8
if (size === 1) {
if (pos + 16 > end) return
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
size = Number(view.getBigUint64(pos + 8))
headerSize = 16
}
if (size < headerSize) return
const boxEnd = pos + size
if (boxEnd > end) return
yield { type, start: pos + headerSize, end: boxEnd }
pos = boxEnd
}
}
function findBox(
data: Uint8Array,
start: number,
end: number,
type: string
): BoxRange | undefined {
for (const box of iterateBoxes(data, start, end)) {
if (box.type === type) return box
}
return undefined
}
function findBoxDeep(
data: Uint8Array,
root: BoxRange,
type: string
): BoxRange | undefined {
const direct = findBox(data, root.start, root.end, type)
if (direct) return direct
for (const child of iterateBoxes(data, root.start, root.end)) {
const nested = findBoxDeep(data, child, type)
if (nested) return nested
}
return undefined
}
function isVideoTrack(data: Uint8Array, trak: BoxRange): boolean {
const handler = findBoxDeep(data, trak, 'hdlr')
if (!handler || handler.start + 12 > handler.end) return false
return readBoxType(data, handler.start + 8) === 'vide'
}
function readUint64(data: Uint8Array, offset: number): number {
if (offset + 8 > data.length) return 0
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
return Number(view.getBigUint64(offset))
}
function frameRateFromTrack(
data: Uint8Array,
trak: BoxRange,
durationSeconds: number
): number | undefined {
const mediaHeader = findBoxDeep(data, trak, 'mdhd')
const sampleSizes = findBoxDeep(data, trak, 'stsz')
if (!mediaHeader || !sampleSizes) return undefined
const version = data[mediaHeader.start]
let timescale: number
let mediaDurationTicks: number
if (version === 1) {
timescale = readUint32(data, mediaHeader.start + 20)
mediaDurationTicks = readUint64(data, mediaHeader.start + 24)
} else {
timescale = readUint32(data, mediaHeader.start + 12)
mediaDurationTicks = readUint32(data, mediaHeader.start + 16)
}
const sampleCount = readUint32(data, sampleSizes.start + 8)
if (timescale <= 0 || sampleCount <= 0) return undefined
const trackDurationSeconds =
mediaDurationTicks > 0 ? mediaDurationTicks / timescale : durationSeconds
const duration =
trackDurationSeconds > 0 ? trackDurationSeconds : durationSeconds
if (duration <= 0) return undefined
const frameRate = sampleCount / duration
if (frameRate <= 0 || frameRate > MAX_FRAME_RATE) return undefined
return frameRate
}
export function parseMp4AverageFrameRate(
data: Uint8Array,
durationSeconds: number
): number | undefined {
if (durationSeconds <= 0) return undefined
const movie = findBox(data, 0, data.length, 'moov')
if (!movie) return undefined
for (const track of iterateBoxes(data, movie.start, movie.end)) {
if (track.type !== 'trak' || !isVideoTrack(data, track)) continue
const frameRate = frameRateFromTrack(data, track, durationSeconds)
if (frameRate != null) return frameRate
}
return undefined
}
async function fetchRange(
url: string,
start: number,
end: number
): Promise<ArrayBuffer | undefined> {
try {
const response = await fetch(url, {
headers: { Range: `bytes=${start}-${end}` }
})
if (response.status !== 206) return undefined
return await response.arrayBuffer()
} catch {
return undefined
}
}
export async function probeVideoFrameRate(
url: string,
durationSeconds: number,
byteSize?: number
): Promise<number | undefined> {
if (durationSeconds <= 0) return undefined
const resolvedByteSize = byteSize ?? (await fetchHttpResourceByteSize(url))
const chunks: Uint8Array[] = []
const leading = await fetchRange(url, 0, PROBE_CHUNK_BYTES - 1)
if (leading) chunks.push(new Uint8Array(leading))
if (resolvedByteSize != null && resolvedByteSize > PROBE_CHUNK_BYTES) {
const trailingStart = Math.max(0, resolvedByteSize - PROBE_CHUNK_BYTES)
const trailing = await fetchRange(
url,
trailingStart,
Math.max(trailingStart, resolvedByteSize - 1)
)
if (trailing) chunks.push(new Uint8Array(trailing))
}
for (const chunk of chunks) {
const frameRate = parseMp4AverageFrameRate(chunk, durationSeconds)
if (frameRate != null) return frameRate
}
return undefined
}

View File

@@ -0,0 +1,91 @@
import { computed } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import {
useLoadVideoPreview,
nodeHasLoadVideoPreview
} from './useLoadVideoPreview'
const { getNodeImageUrlsMock } = vi.hoisted(() => ({
getNodeImageUrlsMock: vi.fn<(node: unknown) => string[] | undefined>(
() => undefined
)
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
nodeOutputs: {},
getNodeImageUrls: getNodeImageUrlsMock
})
}))
vi.mock('@/scripts/app', () => ({
app: {
getPreviewFormatParam: () => ''
}
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: (path: string) => `https://example.test${path}`
}
}))
describe('useLoadVideoPreview', () => {
it('falls back to the file widget value when node outputs are unavailable', () => {
getNodeImageUrlsMock.mockReturnValue(undefined)
const node = computed(() => ({
widgets: [{ name: 'file', value: 'clip.mp4' }]
}))
const { videoUrl } = useLoadVideoPreview(node as never)
expect(videoUrl.value).toBe(
'https://example.test/view?filename=clip.mp4&subfolder=&type=input'
)
})
it('prefers node output preview urls over the file widget fallback', () => {
getNodeImageUrlsMock.mockReturnValue([
'https://example.test/view?filename=from-output.mp4'
])
const node = computed(() => ({
widgets: [{ name: 'file', value: 'clip.mp4' }]
}))
const { videoUrl } = useLoadVideoPreview(node as never)
expect(videoUrl.value).toBe(
'https://example.test/view?filename=from-output.mp4'
)
})
it('detects preview availability from the file widget fallback', () => {
getNodeImageUrlsMock.mockReturnValue(undefined)
expect(
nodeHasLoadVideoPreview({
widgets: [{ name: 'file', value: 'clip.mp4' }]
} as never)
).toBe(true)
})
it('ignores remote widget placeholder values', () => {
getNodeImageUrlsMock.mockReturnValue(undefined)
const node = computed(() => ({
widgets: [{ name: 'file', value: 'Loading...' }]
}))
const { videoUrl } = useLoadVideoPreview(node as never)
expect(videoUrl.value).toBeUndefined()
expect(
nodeHasLoadVideoPreview({
widgets: [{ name: 'file', value: 'Loading...' }]
} as never)
).toBe(false)
})
})

View File

@@ -0,0 +1,75 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import { parseImageWidgetValue } from '@/utils/imageUtil'
const REMOTE_WIDGET_PLACEHOLDER = 'Loading...'
function isResolvableFileWidgetValue(raw: unknown): raw is string {
if (typeof raw !== 'string' || !raw || raw === REMOTE_WIDGET_PLACEHOLDER) {
return false
}
const { filename } = parseImageWidgetValue(raw)
return Boolean(filename)
}
function resolveVideoUrlFromFileWidget(node: LGraphNode): string | undefined {
const fileWidget = node.widgets?.find((widget) => widget.name === 'file')
const raw = fileWidget?.value
if (!isResolvableFileWidgetValue(raw)) return undefined
const { filename, subfolder, type } = parseImageWidgetValue(raw)
if (!filename) return undefined
const params = new URLSearchParams({ filename, subfolder, type })
appendCloudResParam(params, filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
export function nodeHasLoadVideoPreview(
node: LGraphNode | null | undefined
): boolean {
if (!node) return false
const nodeOutputStore = useNodeOutputStore()
if ((nodeOutputStore.getNodeImageUrls(node)?.length ?? 0) > 0) {
return true
}
return resolveVideoUrlFromFileWidget(node) !== undefined
}
export function useLoadVideoPreview(
node: ComputedRef<LGraphNode | null | undefined>
) {
const nodeOutputStore = useNodeOutputStore()
const widgetValueStore = useWidgetValueStore()
const videoUrl = computed(() => {
const currentNode = node.value
if (!currentNode) return undefined
void nodeOutputStore.nodeOutputs
const graphId = currentNode.graph?.rootGraph?.id
if (graphId) {
void widgetValueStore.getWidget(widgetId(graphId, currentNode.id, 'file'))
?.value
}
return (
nodeOutputStore.getNodeImageUrls(currentNode)?.[0] ??
resolveVideoUrlFromFileWidget(currentNode)
)
})
return { videoUrl }
}

View File

@@ -0,0 +1,198 @@
import { effectScope, nextTick, ref } from 'vue'
import type { EffectScope } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { probeVideoFrameRate } from '@/composables/video/probeVideoFrameRate'
import { fetchHttpResourceByteSize } from '@/utils/httpResourceByteSize'
import {
DEFAULT_VIDEO_FPS,
FILMSTRIP_SAMPLE_COUNT,
useVideoFilmstrip
} from './useVideoFilmstrip'
vi.mock('@/composables/video/probeVideoFrameRate', () => ({
probeVideoFrameRate: vi.fn(async () => undefined)
}))
vi.mock('@/utils/httpResourceByteSize', () => ({
fetchHttpResourceByteSize: vi.fn(async () => undefined)
}))
type VideoListener = (event: Event) => void
class MockVideoElement {
preload = ''
muted = false
playsInline = false
crossOrigin = ''
duration = 10
videoWidth = 512
videoHeight = 512
src = ''
private listeners = new Map<string, Set<VideoListener>>()
set currentTime(_value: number) {
queueMicrotask(() => this.emit('seeked'))
}
addEventListener(type: string, listener: VideoListener, options?: boolean) {
if (options === true) {
const wrapped = (event: Event) => {
this.removeEventListener(type, wrapped)
listener(event)
}
this.getListeners(type).add(wrapped)
return
}
this.getListeners(type).add(listener)
}
removeEventListener(type: string, listener: VideoListener) {
this.getListeners(type).delete(listener)
}
load() {
this.src = ''
}
removeAttribute(name: string) {
if (name === 'src') this.src = ''
}
private getListeners(type: string) {
if (!this.listeners.has(type)) {
this.listeners.set(type, new Set())
}
return this.listeners.get(type)!
}
emit(type: string) {
for (const listener of [...this.getListeners(type)]) {
listener(new Event(type))
}
}
}
function createMockCanvas(): HTMLCanvasElement {
return {
width: 0,
height: 0,
getContext: () => ({
drawImage: vi.fn()
}),
toDataURL: () => 'data:image/jpeg;base64,thumb'
} as unknown as HTMLCanvasElement
}
function installVideoMocks() {
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
if (tagName === 'video') {
const video = new MockVideoElement()
queueMicrotask(() => video.emit('loadedmetadata'))
return video as unknown as HTMLVideoElement
}
if (tagName === 'canvas') {
return createMockCanvas()
}
return originalCreateElement(tagName)
})
}
describe('useVideoFilmstrip', () => {
let scope: EffectScope | undefined
function runWithScope<T>(fn: () => T): T {
scope = effectScope()
return scope.run(fn)!
}
afterEach(() => {
scope?.stop()
scope = undefined
vi.restoreAllMocks()
})
it('estimates total frames from duration and default fps', async () => {
installVideoMocks()
const videoUrl = ref('https://example.com/video.mp4')
const { totalFrames, duration, loading } = runWithScope(() =>
useVideoFilmstrip(videoUrl)
)
await vi.waitFor(() => expect(loading.value).toBe(false))
expect(duration.value).toBe(10)
expect(totalFrames.value).toBe(Math.round(10 * DEFAULT_VIDEO_FPS))
})
it('clears state when url is removed', async () => {
installVideoMocks()
const videoUrl = ref<string | undefined>('https://example.com/video.mp4')
const { thumbnails, totalFrames, loading } = runWithScope(() =>
useVideoFilmstrip(videoUrl)
)
await vi.waitFor(() => expect(loading.value).toBe(false))
videoUrl.value = undefined
await nextTick()
expect(thumbnails.value).toEqual([])
expect(totalFrames.value).toBe(0)
expect(loading.value).toBe(false)
})
it('uses probed frame rate and file size when available', async () => {
installVideoMocks()
vi.mocked(probeVideoFrameRate).mockResolvedValueOnce(24)
vi.mocked(fetchHttpResourceByteSize).mockResolvedValueOnce(5 * 1024 * 1024)
const videoUrl = ref('https://example.com/video.mp4')
const { totalFrames, fps, fileSize, loading } = runWithScope(() =>
useVideoFilmstrip(videoUrl)
)
await vi.waitFor(() => expect(loading.value).toBe(false))
expect(fps.value).toBe(24)
expect(totalFrames.value).toBe(240)
expect(fileSize.value).toBe(5 * 1024 * 1024)
})
it('samples the configured number of frames', async () => {
let seekCount = 0
const originalCreateElement = document.createElement.bind(document)
vi.spyOn(document, 'createElement').mockImplementation((tagName) => {
if (tagName === 'video') {
const video = new MockVideoElement()
video.addEventListener('seeked', () => {
seekCount += 1
})
queueMicrotask(() => video.emit('loadedmetadata'))
return video as unknown as HTMLVideoElement
}
if (tagName === 'canvas') {
return createMockCanvas()
}
return originalCreateElement(tagName)
})
const videoUrl = ref('https://example.com/video.mp4')
const { thumbnails, loading } = runWithScope(() =>
useVideoFilmstrip(videoUrl, {
sampleCount: FILMSTRIP_SAMPLE_COUNT
})
)
await vi.waitFor(() => expect(loading.value).toBe(false))
expect(seekCount).toBe(FILMSTRIP_SAMPLE_COUNT)
expect(thumbnails.value).toHaveLength(FILMSTRIP_SAMPLE_COUNT)
})
})

View File

@@ -0,0 +1,206 @@
import { onScopeDispose, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { probeVideoFrameRate } from '@/composables/video/probeVideoFrameRate'
import { fetchHttpResourceByteSize } from '@/utils/httpResourceByteSize'
export const DEFAULT_VIDEO_FPS = 20
export const FILMSTRIP_SAMPLE_COUNT = 20
interface UseVideoFilmstripOptions {
fps?: number
sampleCount?: number
}
function waitForEvent(target: EventTarget, eventName: string): Promise<Event> {
return new Promise((resolve, reject) => {
const onSuccess = (event: Event) => {
cleanup()
resolve(event)
}
const onError = () => {
cleanup()
reject(new Error(`Failed to load ${eventName}`))
}
const cleanup = () => {
target.removeEventListener(eventName, onSuccess)
target.removeEventListener('error', onError)
}
target.addEventListener(eventName, onSuccess, { once: true })
target.addEventListener('error', onError, { once: true })
})
}
async function captureFrame(
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
context: CanvasRenderingContext2D
): Promise<string> {
const width = video.videoWidth
const height = video.videoHeight
if (width <= 0 || height <= 0) return ''
canvas.width = width
canvas.height = height
context.drawImage(video, 0, 0, width, height)
return canvas.toDataURL('image/jpeg', 0.7)
}
async function sampleFilmstripFrames(
video: HTMLVideoElement,
canvas: HTMLCanvasElement,
context: CanvasRenderingContext2D,
duration: number,
sampleCount: number
): Promise<string[]> {
const thumbnails: string[] = []
const lastIndex = Math.max(sampleCount - 1, 1)
for (let index = 0; index < sampleCount; index++) {
const time = sampleCount <= 1 ? 0 : (duration * index) / lastIndex
video.currentTime = Math.min(time, Math.max(duration - 0.001, 0))
await waitForEvent(video, 'seeked')
const thumbnail = await captureFrame(video, canvas, context)
if (thumbnail) thumbnails.push(thumbnail)
}
return thumbnails
}
export function useVideoFilmstrip(
videoUrl: Ref<string | undefined>,
options: UseVideoFilmstripOptions = {}
) {
const sampleCount = options.sampleCount ?? FILMSTRIP_SAMPLE_COUNT
const thumbnails = ref<string[]>([])
const duration = ref(0)
const totalFrames = ref(0)
const width = ref(0)
const height = ref(0)
const fps = ref(options.fps ?? DEFAULT_VIDEO_FPS)
const fileSize = ref<number | undefined>()
const loading = ref(false)
const error = ref<string | null>(null)
let activeLoadId = 0
function isLoadStale(loadId: number, url: string) {
return loadId !== activeLoadId || videoUrl.value !== url
}
async function loadVideo(url: string) {
const loadId = ++activeLoadId
loading.value = true
error.value = null
thumbnails.value = []
const video = document.createElement('video')
video.preload = 'metadata'
video.muted = true
video.playsInline = true
video.crossOrigin = 'anonymous'
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
if (!context) {
loading.value = false
error.value = 'Canvas is unavailable'
return
}
try {
video.src = url
await waitForEvent(video, 'loadedmetadata')
if (isLoadStale(loadId, url)) return
const videoDuration = Number.isFinite(video.duration) ? video.duration : 0
duration.value = videoDuration
width.value = video.videoWidth
height.value = video.videoHeight
const detectedFileSize = await fetchHttpResourceByteSize(url)
if (isLoadStale(loadId, url)) return
const detectedFrameRate = await probeVideoFrameRate(
url,
videoDuration,
detectedFileSize
)
if (isLoadStale(loadId, url)) return
fps.value = detectedFrameRate ?? options.fps ?? DEFAULT_VIDEO_FPS
fileSize.value = detectedFileSize
totalFrames.value = Math.max(Math.round(videoDuration * fps.value), 1)
const sampledThumbnails = await sampleFilmstripFrames(
video,
canvas,
context,
videoDuration,
sampleCount
)
if (isLoadStale(loadId, url)) return
thumbnails.value = sampledThumbnails
} catch (loadError) {
if (isLoadStale(loadId, url)) return
error.value =
loadError instanceof Error ? loadError.message : 'Failed to load video'
duration.value = 0
totalFrames.value = 0
width.value = 0
height.value = 0
fps.value = options.fps ?? DEFAULT_VIDEO_FPS
fileSize.value = undefined
thumbnails.value = []
} finally {
if (loadId === activeLoadId) {
loading.value = false
}
video.removeAttribute('src')
video.load()
}
}
watch(
videoUrl,
(url) => {
if (!url) {
activeLoadId++
loading.value = false
error.value = null
thumbnails.value = []
duration.value = 0
totalFrames.value = 0
width.value = 0
height.value = 0
fps.value = options.fps ?? DEFAULT_VIDEO_FPS
fileSize.value = undefined
return
}
void loadVideo(url)
},
{ immediate: true }
)
onScopeDispose(() => {
activeLoadId++
})
return {
thumbnails,
duration,
totalFrames,
width,
height,
fps,
fileSize,
loading,
error
}
}

View File

@@ -1,46 +1,14 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyNode } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyExtension } from '@/types/comfy'
import type { GroupNodeWorkflowData } from './groupNode'
const appMock = vi.hoisted(() => ({
canvas: {
emitAfterChange: vi.fn(),
emitBeforeChange: vi.fn(),
selected_nodes: {}
},
registerExtension: vi.fn(),
registerNodeDef: vi.fn(),
rootGraph: {
convertToSubgraph: vi.fn(),
extra: {},
getNodeById: vi.fn(),
links: {},
nodes: [],
remove: vi.fn()
}
}))
const widgetStoreMock = vi.hoisted(() => ({
inputIsWidget: vi.fn((spec: unknown[]) =>
['BOOLEAN', 'COMBO', 'FLOAT', 'INT', 'STRING'].includes(String(spec[0]))
)
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => widgetStoreMock
app: {
registerExtension: vi.fn()
}
}))
import { GroupNodeConfig, replaceLegacySeparators } from './groupNode'
@@ -58,42 +26,6 @@ function makeNode(type: string): ComfyNode {
}
}
function makeNodeDef(overrides: Partial<ComfyNodeDef> = {}): ComfyNodeDef {
return {
name: 'TestNode',
display_name: 'Test Node',
description: '',
category: 'test',
input: { required: {}, optional: {} },
output: [],
output_name: [],
output_is_list: [],
output_node: false,
python_module: 'test',
...overrides
} as ComfyNodeDef
}
function extension(): ComfyExtension {
const groupExtension = appMock.registerExtension.mock.calls.find(
([registered]) => registered.name === 'Comfy.GroupNode'
)?.[0]
if (!groupExtension) throw new Error('GroupNode extension was not registered')
return groupExtension as ComfyExtension
}
function addCustomNodeDefs(defs: Record<string, ComfyNodeDef>) {
extension().addCustomNodeDefs?.(defs, appMock as unknown as ComfyApp)
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
appMock.registerNodeDef.mockReset()
widgetStoreMock.inputIsWidget.mockClear()
LiteGraph.registered_node_types = {}
addCustomNodeDefs({})
})
describe('replaceLegacySeparators', () => {
it('rewrites the legacy "workflow/" prefix to "workflow>"', () => {
const nodes = [makeNode('workflow/My Group')]
@@ -172,389 +104,4 @@ describe('GroupNodeConfig.getLinks', () => {
const config = configFrom([], [[0, 1, 'IMAGE']])
expect(config.externalFrom[0][1]).toBe('IMAGE')
})
it('ignores external links without a type and accumulates multiple slots', () => {
const config = configFrom(
[],
[
[0, 1, null as unknown as string],
[0, 2, 'LATENT'],
[0, 3, 'IMAGE']
]
)
expect(config.externalFrom[0]).toEqual({ 2: 'LATENT', 3: 'IMAGE' })
})
})
describe('GroupNodeConfig.getNodeDef', () => {
const imageNodeDef = makeNodeDef({
name: 'ImageNode',
input: {
required: {
image: ['IMAGE', {}],
mode: [['fast', 'slow'], {}]
},
optional: {
strength: ['FLOAT', { default: 1 }]
}
},
output: ['IMAGE'],
output_name: ['image'],
output_is_list: [false]
})
beforeEach(() => {
addCustomNodeDefs({ ImageNode: imageNodeDef })
})
it('returns registered definitions for normal node types', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ index: 0, type: 'ImageNode' }],
links: [],
external: []
})
expect(config.getNodeDef({ index: 0, type: 'ImageNode' })).toBe(
imageNodeDef
)
})
it('returns undefined for nodes without an index or a known type', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ type: 'UnknownNode' }],
links: [],
external: []
})
expect(config.getNodeDef({ type: 'UnknownNode' })).toBeUndefined()
})
it('skips unlinked primitive nodes', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ index: 0, type: 'PrimitiveNode' }],
links: [],
external: []
})
expect(
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
).toBeUndefined()
})
it('derives primitive node type from the outgoing link type', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'PrimitiveNode' },
{ index: 1, type: 'ImageNode' }
],
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
external: []
})
expect(
config.getNodeDef({ index: 0, type: 'PrimitiveNode' })
).toMatchObject({
input: { required: { value: ['IMAGE', {}] } },
output: ['IMAGE']
})
})
it('falls back to null when primitive combo target spec is not primitive', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{
index: 0,
type: 'PrimitiveNode',
outputs: [{ name: 'mode', widget: { name: 'mode' } }]
},
{ index: 1, type: 'ImageNode' }
],
links: [[0, 0, 1, 0, 1, 'COMBO'] as SerialisedLLinkArray],
external: []
})
expect(config.getNodeDef(config.nodeData.nodes[0])).toMatchObject({
input: { required: { value: [null, {}] } },
output: [null]
})
})
it('returns null for reroutes used only inside the group', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'ImageNode' },
{ index: 1, type: 'Reroute' },
{ index: 2, type: 'ImageNode' }
],
links: [
[0, 0, 1, 0, 1, 'IMAGE'],
[1, 0, 2, 0, 2, 'IMAGE']
] as SerialisedLLinkArray[],
external: []
})
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toBeNull()
})
it('derives reroute type from outgoing target inputs', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'Reroute' },
{
index: 1,
type: 'ImageNode',
inputs: [{ name: 'image', type: 'IMAGE' }]
}
],
links: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray],
external: [[0, 0, 'IMAGE']]
})
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
input: { required: { IMAGE: ['IMAGE', { forceInput: true }] } },
output: ['IMAGE']
})
})
it('derives reroute type from incoming output metadata', () => {
const config = new GroupNodeConfig('group', {
nodes: [
{ index: 0, type: 'ImageNode', outputs: [{ type: 'LATENT' }] },
{ index: 1, type: 'Reroute' }
],
links: [[0, 0, 1, 0, 1, 'LATENT'] as SerialisedLLinkArray],
external: [[1, 0, 'LATENT']]
})
expect(config.getNodeDef({ index: 1, type: 'Reroute' })).toMatchObject({
input: { required: { LATENT: ['LATENT', { forceInput: true }] } },
output: ['LATENT']
})
})
it('derives pipe reroute type from external metadata when links omit it', () => {
const config = new GroupNodeConfig('group', {
nodes: [{ index: 0, type: 'Reroute' }],
links: [],
external: [[0, 0, 'MASK']]
})
expect(config.getNodeDef({ index: 0, type: 'Reroute' })).toMatchObject({
input: { required: { MASK: ['MASK', { forceInput: true }] } },
output: ['MASK']
})
})
})
describe('GroupNodeConfig input and output mapping', () => {
function configWithNode(node: GroupNodeWorkflowData['nodes'][number]) {
const config = new GroupNodeConfig('group', {
nodes: [node],
links: [],
external: [],
config: {
0: {
input: {
hidden: { visible: false },
renamed: { name: 'Custom Name' }
},
output: {
1: { name: 'Custom Output' },
2: { visible: false }
}
}
}
})
config.nodeDef = makeNodeDef({
input: { required: {} },
output: [],
output_name: [],
output_is_list: []
})
return config
}
it('renames duplicate inputs and adds seed control metadata', () => {
const config = configWithNode({
index: 0,
type: 'Sampler',
title: 'Sampler A',
inputs: [{ name: 'seed', label: 'Seed Label' }]
})
const seenInputs = { seed: 1, 'Sampler A seed': 1 }
const result = config.getInputConfig(
{ index: 0, type: 'Sampler', title: 'Sampler A' },
'seed',
seenInputs,
['INT', {}]
)
expect(result.name).toBe('Sampler A 1 seed')
expect(result.config).toEqual([
'INT',
{ control_after_generate: 'Sampler A control_after_generate' }
])
})
it('maps image upload widget aliases through converted widget names', () => {
const config = configWithNode({ index: 0, type: 'LoadImage' })
config.oldToNewWidgetMap[0] = { customImage: 'Uploaded Image' }
expect(
config.getInputConfig({ index: 0, type: 'LoadImage' }, 'renamed', {}, [
'IMAGEUPLOAD',
{ widget: 'customImage' }
])
).toMatchObject({
name: 'Custom Name',
config: ['IMAGEUPLOAD', { widget: 'Uploaded Image' }]
})
})
it('splits widget inputs, socket inputs, and converted widget slots', () => {
const config = configWithNode({
index: 0,
type: 'MixedNode',
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
})
const result = config.processWidgetInputs(
{
mode: ['COMBO', {}],
image: ['IMAGE', {}]
},
{
index: 0,
type: 'MixedNode',
inputs: [{ name: 'mode', widget: { name: 'mode' } }]
},
['mode', 'image'],
{}
)
expect(result.slots).toEqual(['image'])
expect(result.converted.get(0)).toBe('mode')
expect(config.oldToNewWidgetMap[0].mode).toBeNull()
})
it('adds visible unlinked input slots and skips hidden configured inputs', () => {
const config = configWithNode({
index: 0,
type: 'InputNode'
})
const inputMap: Record<number, number> = {}
config.processInputSlots(
{
image: ['IMAGE', {}],
hidden: ['LATENT', {}]
},
{ index: 0, type: 'InputNode' },
['image', 'hidden'],
{},
inputMap,
{}
)
expect(config.nodeDef?.input?.required).toEqual({ image: ['IMAGE', {}] })
expect(inputMap).toEqual({ 0: 0 })
})
it('adds output metadata, hides linked/internal outputs, and dedupes labels', () => {
const config = configWithNode({
index: 0,
type: 'OutputNode',
title: 'Output A',
outputs: [{ name: 'image', label: 'Rendered' }]
})
config.linksFrom[0] = {
0: [[0, 0, 1, 0, 1, 'IMAGE'] as SerialisedLLinkArray]
}
config.processNodeOutputs(
{ index: 0, type: 'OutputNode', title: 'Output A' },
{ Rendered: 1 },
{
input: { required: {} },
output: ['IMAGE', 'LATENT', 'MASK'],
output_name: ['image', 'latent', 'mask'],
output_is_list: [false, true, false]
}
)
expect(config.outputVisibility).toEqual([false, true, false])
expect(config.nodeDef?.output).toEqual(['LATENT'])
expect(config.nodeDef?.output_is_list).toEqual([true])
expect(config.nodeDef?.output_name).toEqual(['Custom Output'])
})
})
describe('GroupNodeConfig.registerFromWorkflow', () => {
it('adds missing type actions and skips registration for incomplete groups', async () => {
const groupNodes: Record<string, GroupNodeWorkflowData> = {
Broken: {
nodes: [{ index: 0, type: 'MissingNode' }],
links: [],
external: []
}
}
const missingNodeTypes: Parameters<
typeof GroupNodeConfig.registerFromWorkflow
>[1] = []
await GroupNodeConfig.registerFromWorkflow(groupNodes, missingNodeTypes)
expect(appMock.registerNodeDef).not.toHaveBeenCalled()
expect(missingNodeTypes).toHaveLength(2)
expect(missingNodeTypes[0]).toMatchObject({
type: 'MissingNode',
hint: " (In group node 'workflow>Broken')"
})
const action = missingNodeTypes[1]
if (typeof action !== 'string') {
const target = document.createElement('button')
const { callback } = action.action as {
callback: (event: MouseEvent) => void
}
const event = new MouseEvent('click')
Object.defineProperty(event, 'target', { value: target })
callback(event)
expect(groupNodes.Broken).toBeUndefined()
expect(target.textContent).toBe('Removed')
expect(target.style.pointerEvents).toBe('none')
}
})
it('registers complete group node types and stores their generated node defs', async () => {
addCustomNodeDefs({
ImageNode: makeNodeDef({
name: 'ImageNode',
input: { required: { image: ['IMAGE', {}] } },
output: ['IMAGE'],
output_name: ['image'],
output_is_list: [false]
})
})
LiteGraph.registered_node_types.ImageNode = class extends LGraphNode {}
await GroupNodeConfig.registerFromWorkflow(
{
Complete: {
nodes: [{ index: 0, type: 'ImageNode' }],
links: [],
external: [[0, 0, 'IMAGE']]
}
},
[]
)
expect(appMock.registerNodeDef).toHaveBeenCalledWith(
'workflow>Complete',
expect.objectContaining({
category: 'group nodes>workflow',
display_name: 'Complete',
name: 'workflow>Complete'
})
)
})
})

View File

@@ -815,8 +815,10 @@ export class GroupNodeConfig {
* `configure`. The load-time migration unpacks each instance via
* {@link convertToNodes} and {@link LGraph.convertToSubgraph} repackages the
* result as a subgraph.
*
* @knipIgnoreUnusedButUsedByCustomNodes
*/
class GroupNodeHandler {
export class GroupNodeHandler {
node: LGraphNode
groupData: GroupNodeConfig

View File

@@ -13,6 +13,7 @@ import './imageCompare'
import './imageCrop'
// load3d and saveMesh are loaded on-demand to defer THREE.js (~1.8MB)
// The lazy loader triggers loading when a 3D node is used
import './loadVideoTrim'
import './load3dLazy'
import './maskeditor'
if (!isCloud) {

View File

@@ -0,0 +1,17 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useExtensionService } from '@/services/extensionService'
import { useVideoTrimWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useVideoTrimWidget'
useExtensionService().registerExtension({
name: 'Comfy.LoadVideoTrimPrototype',
nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'LoadVideo') return
node.hideOutputImages = true
node.setSize([Math.max(node.size[0], 350), node.size[1]])
useVideoTrimWidget(node)
}
})

View File

@@ -146,6 +146,7 @@ export type IWidget =
| ICurveWidget
| IPainterWidget
| IRangeWidget
| IVideoTrimWidget
| IBoundingBoxesWidget
| IColorsWidget
@@ -369,6 +370,12 @@ export interface RangeValue {
midpoint?: number
}
export interface VideoTrimValue {
trimEnabled: boolean
startFrame: number
endFrame: number
}
export interface IWidgetRangeOptions extends IWidgetOptions {
display?: 'plain' | 'gradient' | 'histogram'
gradient_stops?: ColorStop[]
@@ -387,6 +394,14 @@ export interface IRangeWidget extends IBaseWidget<
value: RangeValue
}
export interface IVideoTrimWidget extends IBaseWidget<
VideoTrimValue,
'videotrim'
> {
type: 'videotrim'
value: VideoTrimValue
}
/**
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
* Override linkedWidgets[]

View File

@@ -0,0 +1,16 @@
import type { IVideoTrimWidget } from '../types/widgets'
import { BaseWidget } from './BaseWidget'
import type { DrawWidgetOptions, WidgetEventOptions } from './BaseWidget'
export class VideoTrimWidget
extends BaseWidget<IVideoTrimWidget>
implements IVideoTrimWidget
{
override type = 'videotrim' as const
drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
this.drawVueOnlyWarning(ctx, options, 'Video Trim')
}
onClick(_options: WidgetEventOptions): void {}
}

View File

@@ -25,6 +25,7 @@ import { BoundingBoxesWidget } from './BoundingBoxesWidget'
import { ColorsWidget } from './ColorsWidget'
import { PainterWidget } from './PainterWidget'
import { RangeWidget } from './RangeWidget'
import { VideoTrimWidget } from './VideoTrimWidget'
import { ImageCropWidget } from './ImageCropWidget'
import { KnobWidget } from './KnobWidget'
import { LegacyWidget } from './LegacyWidget'
@@ -64,6 +65,7 @@ export type WidgetTypeMap = {
curve: CurveWidget
painter: PainterWidget
range: RangeWidget
videotrim: VideoTrimWidget
boundingboxes: BoundingBoxesWidget
colors: ColorsWidget
[key: string]: BaseWidget
@@ -148,6 +150,8 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(PainterWidget, narrowedWidget, node)
case 'range':
return toClass(RangeWidget, narrowedWidget, node)
case 'videotrim':
return toClass(VideoTrimWidget, narrowedWidget, node)
case 'boundingboxes':
return toClass(BoundingBoxesWidget, narrowedWidget, node)
case 'colors':

View File

@@ -3017,6 +3017,7 @@
"placeholderImage": "Select image...",
"placeholderAudio": "Select audio...",
"placeholderVideo": "Select video...",
"browseAssetLibrary": "Browse asset library",
"placeholderMesh": "Select mesh...",
"placeholderModel": "Select model...",
"placeholderUnknown": "Select media...",
@@ -4477,6 +4478,32 @@
"continueLocally": "Continue Locally",
"exploreCloud": "Try Cloud for Free"
},
"loadVideoTrim": {
"trimVideo": "Trim Video",
"startFrame": "Start Frame",
"endFrame": "End Frame",
"setStartFrame": "Set start frame",
"setEndFrame": "Set end frame",
"duration": "Duration",
"frames": "Number of Frames",
"fileSize": "File Size",
"resolution": "{width} × {height}",
"play": "Play",
"pause": "Pause",
"dragAndDropVideos": "Drag and drop videos here to upload",
"uploadFromDevice": "Upload from device",
"uploading": "Uploading…",
"loadingVideo": "Loading video preview",
"loadingFilmstrip": "Loading filmstrip…",
"adjustStartFrame": "Adjust start frame",
"adjustEndFrame": "Adjust end frame",
"durationZero": "0s",
"durationSeconds": "{count}s",
"fileSizeUnknown": "—",
"fileSizeBytes": "{count} B",
"fileSizeKilobytes": "{count} KB",
"fileSizeMegabytes": "{count} MB"
},
"execution": {
"generating": "Generating…",
"saving": "Saving…",

View File

@@ -9,13 +9,6 @@ import { i18n } from '@/i18n'
const flushPromises = () =>
new Promise<void>((resolve) => setTimeout(resolve, 0))
const trackSettingChanged = vi.fn()
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackSettingChanged
}))
}))
const mockGet = vi.fn()
const mockSet = vi.fn()
vi.mock('@/platform/settings/settingStore', () => ({
@@ -40,7 +33,7 @@ const FormItemUpdateStub = defineComponent({
template: '<div data-testid="form-item-stub" />'
})
describe('SettingItem (telemetry UI tracking)', () => {
describe('SettingItem', () => {
beforeEach(() => {
vi.clearAllMocks()
emitFormValue = null
@@ -61,15 +54,15 @@ describe('SettingItem (telemetry UI tracking)', () => {
})
}
it('tracks telemetry when value changes via UI (uses normalized value)', async () => {
it('persists setting updates through the setting store', async () => {
const settingParams: SettingParams = {
id: 'main.sub.setting.name',
name: 'Telemetry Visible',
name: 'Visible Setting',
type: 'text',
defaultValue: 'default'
}
mockGet.mockReturnValueOnce('default').mockReturnValueOnce('normalized')
mockGet.mockReturnValue('default')
mockSet.mockResolvedValue(undefined)
renderComponent(settingParams)
@@ -78,33 +71,6 @@ describe('SettingItem (telemetry UI tracking)', () => {
await flushPromises()
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
expect(trackSettingChanged).toHaveBeenCalledWith(
expect.objectContaining({
setting_id: 'main.sub.setting.name',
previous_value: 'default',
new_value: 'normalized'
})
)
})
it('does not track telemetry when normalized value does not change', async () => {
const settingParams: SettingParams = {
id: 'main.sub.setting.name',
name: 'Telemetry Visible',
type: 'text',
defaultValue: 'same'
}
mockGet.mockReturnValueOnce('same').mockReturnValueOnce('same')
mockSet.mockResolvedValue(undefined)
renderComponent(settingParams)
emitFormValue!('same')
await flushPromises()
expect(trackSettingChanged).not.toHaveBeenCalled()
expect(mockSet).toHaveBeenCalledWith('main.sub.setting.name', 'newvalue')
})
})

View File

@@ -31,7 +31,6 @@ import FormItem from '@/components/common/FormItem.vue'
import { st } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingOption, SettingParams } from '@/platform/settings/types'
import { useTelemetry } from '@/platform/telemetry'
import type { Settings } from '@/schemas/apiSchema'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -81,19 +80,6 @@ const settingValue = computed(() => settingStore.get(props.setting.id))
const updateSettingValue = async <K extends keyof Settings>(
newValue: Settings[K]
) => {
const telemetry = useTelemetry()
const settingId = props.setting.id
const previousValue = settingValue.value
await settingStore.set(settingId, newValue)
const normalizedValue = settingStore.get(settingId)
if (previousValue !== normalizedValue) {
telemetry?.trackSettingChanged({
setting_id: settingId,
previous_value: previousValue,
new_value: normalizedValue
})
}
await settingStore.set(props.setting.id, newValue)
}
</script>

View File

@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import {
getSettingInfo,
@@ -10,47 +11,31 @@ import type { SettingTreeNode } from '@/platform/settings/settingStore'
import { useSettingUI } from './useSettingUI'
const { auth, billing, dist, featureFlags, vueFlags } = vi.hoisted(() => ({
auth: { isLoggedIn: { value: false } },
billing: { isActiveSubscription: { value: false } },
dist: { isCloud: false, isDesktop: false },
featureFlags: { teamWorkspacesEnabled: false, userSecretsEnabled: false },
vueFlags: { shouldRenderVueNodes: { value: false } }
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (_: string, fallback: string) => fallback })
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({ isLoggedIn: auth.isLoggedIn })
useCurrentUser: () => ({ isLoggedIn: ref(false) })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: billing.isActiveSubscription
})
useBillingContext: () => ({ isActiveSubscription: ref(false) })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: featureFlags
flags: { teamWorkspacesEnabled: false, userSecretsEnabled: false }
})
}))
vi.mock('@/composables/useVueFeatureFlags', () => ({
useVueFeatureFlags: () => ({
shouldRenderVueNodes: vueFlags.shouldRenderVueNodes
})
useVueFeatureFlags: () => ({ shouldRenderVueNodes: ref(false) })
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return dist.isCloud
},
get isDesktop() {
return dist.isDesktop
}
isCloud: false,
isDesktop: false
}))
vi.mock('@/platform/settings/settingStore', () => ({
@@ -64,7 +49,6 @@ interface MockSettingParams {
type: string
defaultValue: unknown
category?: string[]
hideInVueNodes?: boolean
}
describe('useSettingUI', () => {
@@ -88,23 +72,13 @@ describe('useSettingUI', () => {
defaultValue: 'dark'
}
}
let settingsById: Record<string, MockSettingParams>
beforeEach(() => {
setActivePinia(createTestingPinia())
vi.clearAllMocks()
auth.isLoggedIn.value = false
billing.isActiveSubscription.value = false
dist.isCloud = false
dist.isDesktop = false
featureFlags.teamWorkspacesEnabled = false
featureFlags.userSecretsEnabled = false
vueFlags.shouldRenderVueNodes.value = false
Object.assign(window, { __CONFIG__: {} })
settingsById = mockSettings
vi.mocked(useSettingStore).mockReturnValue({
settingsById
settingsById: mockSettings
} as ReturnType<typeof useSettingStore>)
vi.mocked(getSettingInfo).mockImplementation((setting) => {
@@ -133,9 +107,9 @@ describe('useSettingUI', () => {
undefined,
'Comfy.Locale'
)
expect(defaultCategory.value).toBe(
findCategory(settingCategories.value, 'Comfy')
)
const comfyCategory = findCategory(settingCategories.value, 'Comfy')
expect(comfyCategory).toBeDefined()
expect(defaultCategory.value).toBe(comfyCategory)
})
it('resolves different category from scrollToSettingId', () => {
@@ -147,6 +121,7 @@ describe('useSettingUI', () => {
settingCategories.value,
'Appearance'
)
expect(appearanceCategory).toBeDefined()
expect(defaultCategory.value).toBe(appearanceCategory)
})
@@ -162,82 +137,4 @@ describe('useSettingUI', () => {
const { defaultCategory } = useSettingUI('about', 'Comfy.Locale')
expect(defaultCategory.value.key).toBe('about')
})
it('falls back when defaultPanel is not in the menu', () => {
const missingPanel = 'missing' as unknown as Parameters<
typeof useSettingUI
>[0]
const { defaultCategory, settingCategories } = useSettingUI(missingPanel)
expect(defaultCategory.value).toBe(settingCategories.value[0])
})
it('moves floating settings into Other and hides Vue-node-only settings', () => {
settingsById = {
Floating: {
id: 'Floating',
name: 'Floating',
type: 'boolean',
defaultValue: false
},
'Hidden.Setting': {
id: 'Hidden.Setting',
name: 'Hidden',
type: 'hidden',
defaultValue: false
},
'Vue.Hidden': {
id: 'Vue.Hidden',
name: 'Vue Hidden',
type: 'boolean',
defaultValue: false,
hideInVueNodes: true
}
}
vi.mocked(useSettingStore).mockReturnValue({
settingsById
} as ReturnType<typeof useSettingStore>)
vueFlags.shouldRenderVueNodes.value = true
const { settingCategories } = useSettingUI()
expect(settingCategories.value.map((category) => category.label)).toEqual([
'Other'
])
expect(
settingCategories.value[0].children?.map((node) => node.key)
).toEqual(['root/Floating'])
})
it('adds gated cloud, desktop, workspace, and secrets panels', () => {
auth.isLoggedIn.value = true
billing.isActiveSubscription.value = true
dist.isCloud = true
dist.isDesktop = true
featureFlags.teamWorkspacesEnabled = true
featureFlags.userSecretsEnabled = true
Object.assign(window, { __CONFIG__: { subscription_required: true } })
const { findCategoryByKey, findPanelByKey, navGroups, panels } =
useSettingUI()
expect(panels.value.map((panel) => panel.node.key)).toEqual([
'about',
'credits',
'user',
'workspace',
'keybinding',
'extension',
'server-config',
'subscription',
'secrets'
])
expect(navGroups.value.map((group) => group.title)).toEqual([
'Workspace',
'General'
])
expect(findCategoryByKey('secrets')?.key).toBe('secrets')
expect(findCategoryByKey('missing')).toBeNull()
expect(findPanelByKey('subscription')?.node.key).toBe('subscription')
expect(findPanelByKey('missing')).toBeNull()
})
})

View File

@@ -945,6 +945,7 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: 'dark',
versionModified: '1.6.7',
telemetry: { trackChanges: true, includeValues: true },
migrateDeprecatedValue(val: unknown) {
const value = val as string
// Legacy custom palettes were prefixed with 'custom_'

View File

@@ -11,6 +11,16 @@ import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
const { trackSettingChanged } = vi.hoisted(() => ({
trackSettingChanged: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: vi.fn(() => ({
trackSettingChanged
}))
}))
// Mock the api
vi.mock('@/scripts/api', () => ({
api: {
@@ -399,11 +409,6 @@ describe('useSettingStore', () => {
expect(dispatchChangeMock).toHaveBeenCalledTimes(2)
expect(api.storeSetting).toHaveBeenCalledWith('test.setting', 'newvalue')
// Set the same value again, it should not trigger onChange
await store.set('test.setting', 'newvalue')
expect(onChangeMock).toHaveBeenCalledTimes(2)
expect(dispatchChangeMock).toHaveBeenCalledTimes(2)
// Set a different value, it should trigger onChange
await store.set('test.setting', 'differentvalue')
expect(onChangeMock).toHaveBeenCalledWith('differentvalue', 'newvalue')
@@ -415,6 +420,120 @@ describe('useSettingStore', () => {
)
})
it('tracks visible settings with values by default', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default'
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'test.setting',
previous_value: 'default',
new_value: 'newvalue'
})
})
it('does not track hidden settings by default', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'hidden',
defaultValue: 'default'
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).not.toHaveBeenCalled()
})
it('does not track visible settings that opt out', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
telemetry: { trackChanges: false }
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).not.toHaveBeenCalled()
})
it('tracks visible settings without values when values opt out', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
telemetry: { includeValues: false }
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'test.setting'
})
})
it('tracks hidden settings that opt in, without shipping values by default', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'hidden',
defaultValue: 'default',
telemetry: { trackChanges: true }
})
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'test.setting'
})
// Setting the same value again is a no-op and should not re-emit
await store.set('test.setting', 'newvalue')
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
})
it('ships previous/new values when the setting opts into includeValues', async () => {
store.addSetting({
id: 'Comfy.ColorPalette',
name: 'The active color palette id',
type: 'hidden',
defaultValue: 'dark',
telemetry: { trackChanges: true, includeValues: true }
})
await store.set('Comfy.ColorPalette', 'light')
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'Comfy.ColorPalette',
previous_value: 'dark',
new_value: 'light'
})
})
it('does not track telemetry when persistence fails', async () => {
store.addSetting({
id: 'test.setting',
name: 'test.setting',
type: 'text',
defaultValue: 'default',
telemetry: { trackChanges: true }
})
vi.mocked(api.storeSetting).mockRejectedValueOnce(new Error('failed'))
await expect(store.set('test.setting', 'newvalue')).rejects.toThrow(
'failed'
)
expect(trackSettingChanged).not.toHaveBeenCalled()
})
describe('object mutation prevention', () => {
beforeEach(() => {
const setting: SettingParams = {
@@ -542,6 +661,34 @@ describe('useSettingStore', () => {
expect(api.storeSetting).not.toHaveBeenCalled()
})
it('tracks only the settings in a batch that opt in', async () => {
store.addSetting({
id: 'Comfy.ColorPalette',
name: 'The active color palette id',
type: 'hidden',
defaultValue: 'dark',
telemetry: { trackChanges: true, includeValues: true }
})
store.addSetting({
id: 'Comfy.Release.Version',
name: 'Release Version',
type: 'hidden',
defaultValue: ''
})
await store.setMany({
'Comfy.ColorPalette': 'light',
'Comfy.Release.Version': '1.0.0'
})
expect(trackSettingChanged).toHaveBeenCalledTimes(1)
expect(trackSettingChanged).toHaveBeenCalledWith({
setting_id: 'Comfy.ColorPalette',
previous_value: 'dark',
new_value: 'light'
})
})
it('should skip unchanged values', async () => {
store.addSetting({
id: 'Comfy.Release.Version',
@@ -581,6 +728,7 @@ describe('useSettingStore', () => {
await store.setMany({ 'Comfy.Release.Version': 'existing' })
expect(api.storeSettings).not.toHaveBeenCalled()
expect(trackSettingChanged).not.toHaveBeenCalled()
})
})
})

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