Compare commits

..

23 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
Maanil Verma
3377b8e07e feat(publish): prefill prior metadata and update published workflows in place (#13139)
> **Goal: make updating a published Hub workflow painless in admin
panel.** Today a admin who needs to change a
> published workflow has to reject + reshare + republish, which breaks
the share link, resets stats,
> and blanks the thumbnail — and the rejected backlog can't be cleared.
This is one of 3 PRs that fix it:
>
> | PR | Repo | Fixes |
> |----|------|-------|
> | [#4505](https://github.com/Comfy-Org/cloud/pull/4505) | `cloud` |
Re-publish stops losing data; the editor can read its own published
metadata; reviewer search + admin delete |
> | #13139 | `ComfyUI_frontend` | The publish dialog prefills prior
metadata + thumbnail and updates in place |
> | [#10 ](https://github.com/Comfy-Org/comfy-admin-panels/pull/10)|
`comfy-admin-panels` | Admins can delete rejected/stale workflows |
>
> **Merge order:** `cloud` first (the others depend on its endpoints),
then `ComfyUI_frontend` and
> `comfy-admin-panels` in either order.

## Summary

Editor side of the same effort. Re-opening the publish dialog for an
already-published workflow now
prefills its description, tags, and thumbnail, restores the thumbnail
across step/type changes, keeps
the local workflow name in sync, and labels the action "Update" instead
of "Publish". Depends on the
`cloud` PR — that's what finally returns the `share_id` the dialog reads
from.

## Changes

- **What**:
- **Prefill the thumbnail** — `extractPrefill` dropped `thumbnail_url` /
`thumbnail_comparison_url`,
so only the thumbnail *type* was remembered, never the image. Thread the
URLs into `PublishPrefill`
and restore them; on submit, send the existing URL when no new file is
attached (reuses the
`sampleImageUrls` precedent — an existing URL, not a `File`), so
re-publishing doesn't blank it.
- **Uploads survive navigation** — the thumbnail step kept its `File` in
local component state, so
leaving the step and coming back blanked a fresh upload. The step is now
controlled — files live in
    the form data, the single source of truth that survives the remount.
- **Type-gate the prefilled image** — a restored image must only show on
the tab it belongs to;
`existingThumbnailType` keeps an image off the video tab (which was
hiding the upload prompt) while
    still restoring it when you toggle back.
- **Refetch on rename** — the dialog is a reused singleton, so
`onMounted` fires once; a watch on the
active workflow path refetches prefill when a rename changes it (the
description was going stale).
- **Name sync** — editing the name field published a new Hub display
name but never renamed the local
workflow, so the editor tab (and a reload) kept the old name. Publish
now renames the local file
    when the chosen name differs.
- **"Update" CTA** — the intro panel and footer read "Update" (not
"Publish") when the workflow is
already published, and note that the share link + stats are preserved.

## Review Focus

- `existingThumbnailType` is the load-bearing bit for both the preview
and submit gating — confirm an
image prefill never submits as a video after a type toggle, and that
toggling back restores it.
- Name sync renames *after* a successful publish and is non-fatal on
failure (toast + keep the publish).
The Hub record is keyed by workflow ID, so the rename doesn't orphan it
— worth a sanity check.

## Screenshots



https://github.com/user-attachments/assets/99dd9eff-987f-4ddb-9cf1-e9b40f61e7dc
2026-06-27 05:14:17 +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
Christian Byrne
4a2393be48 chore: drop unnecessary exports on file-local types to satisfy knip (#13204)
Current `main` **fails a fresh `knip` run** with 13 unused exported
types (exit 1). They're invisible on main because lint/knip only runs on
`pull_request`/`merge_group`, never on push to main — so merge skew (one
PR adds an export used by file X; a later PR removes X's usage)
accumulates latent failures that ambush backport branches (e.g. #13163,
#13162).

Each of the 13 is `export`ed but referenced only within its own file
(verified 0 importers; ≥2 in-file uses, so not dead code). Fix: drop the
redundant `export`.

Types cleaned: `VideoSource`, `ObjectInfoResponse`,
`PromotedMissingModelWorkflow`, `PixelReadout`, `ResizeDirection`,
`ResizeHandle`, `RunButtonTelemetryOptions`, `ResolvedModelNode`,
`AccountPreconditionContext`, `SubscriptionDialogOptions`,
`MonthlyCreditsUsage`, `MissingMediaReference`, `ResolvedHostWidget`.

Reviewer note: `ResolvedHostWidget` and `ResolvedModelNode` sit under
`renderer/extensions`/`platform/assets`; no in-repo importers, but if
either is intended as published/extension-facing API, prefer a knip
`entry`/`ignore` over un-exporting — flag in review and I'll adjust.

After fresh `knip`: **0 unused exported types**.

Supersedes #13179 (fixed only `AccountPreconditionContext`). Pairs with
the push-gate workflow #13203 — merge this first so that gate is green
on main.

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

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 03:01:59 +00:00
Terry Jia
a451a90868 FE-1150 feat(rightSidePanel): hide 3D viewport widgets from panel (#13206)
## Summary
Add a hideInPanel widget option so a widget still renders on the node
body but is omitted from the right side panel. Apply it to the Three.js
viewport widgets (Load3D, Preview3D, Load3DAdvanced, SaveGLB), whose
non-syncable scene state would diverge if a second instance rendered in
the panel.

App mode and the subgraph editor are unaffected (they filter on
canvasOnly independently).

Discussed with @alexisrolland and @PabloWiedemann 

## Screenshots
before
<img width="2206" height="1181" alt="image"
src="https://github.com/user-attachments/assets/e536871f-65e6-4d6e-aa61-dc981362214f"
/>

after
<img width="2743" height="1295" alt="image"
src="https://github.com/user-attachments/assets/6cc6d252-57ac-464a-a2b7-1ada5ab9e705"
/>
2026-06-26 22:48:47 -04:00
135 changed files with 6947 additions and 249 deletions

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

@@ -1,7 +1,7 @@
/** @knipIgnoreUsedByStackedPR */
export type VideoFormat = 'webm' | 'mp4'
export type VideoSource = {
type VideoSource = {
src: string
type: `video/${VideoFormat}`
format: VideoFormat

View File

@@ -12,7 +12,7 @@ import type {
InputSpec
} from '@/schemas/nodeDefSchema'
export type ObjectInfoResponse = Record<string, ComfyNodeDef>
type ObjectInfoResponse = Record<string, ComfyNodeDef>
type ComboInput = ComboInputSpec | ComboInputSpecV2

View File

@@ -14,7 +14,7 @@ import { toNodeId } from '@/types/nodeId'
const PROMOTED_MODEL_WIDGET_NAME = 'ckpt_name'
export interface PromotedMissingModelWorkflow {
interface PromotedMissingModelWorkflow {
workflowName: string
hostNodeId: number
hostNodeTitle: string

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

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

@@ -39,7 +39,11 @@ const advancedWidgetsSectionDataList = computed((): NodeWidgetsListList => {
const advancedWidgets = widgets
.filter(
(w) =>
!(w.options?.canvasOnly || w.options?.hidden) && w.options?.advanced
!(
w.options?.canvasOnly ||
w.options?.hidden ||
w.options?.hideInPanel
) && w.options?.advanced
)
.map((widget) => ({ node, widget }))
return { widgets: advancedWidgets, node }

View File

@@ -1,10 +1,16 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { Positionable } from '@/lib/litegraph/src/interfaces'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { toNodeId } from '@/types/nodeId'
import { describe, expect, it, beforeEach } from 'vitest'
import {
computedSectionDataList,
flatAndCategorizeSelectedItems,
searchWidgets,
searchWidgetsAndNodes
@@ -129,6 +135,51 @@ describe('searchWidgetsAndNodes', () => {
})
})
describe('computedSectionDataList', () => {
beforeEach(() => {
setActivePinia(createTestingPinia())
})
function createWidget(
name: string,
options: IWidgetOptions = {}
): IBaseWidget {
return { name, type: 'number', options, y: 0 } as IBaseWidget
}
it('omits hideInPanel widgets while keeping the rest on the node', () => {
const node = new LGraphNode('Load3D')
node.widgets = [
createWidget('seed'),
createWidget('viewport', { hideInPanel: true })
]
const { widgetsSectionDataList } = computedSectionDataList([node])
const shownNames = widgetsSectionDataList.value[0].widgets.map(
({ widget }) => widget.name
)
expect(shownNames).toEqual(['seed'])
})
it('hides canvasOnly, hidden, and hideInPanel widgets from the panel', () => {
const node = new LGraphNode('Load3D')
node.widgets = [
createWidget('seed'),
createWidget('preview', { canvasOnly: true }),
createWidget('internal', { hidden: true }),
createWidget('viewport', { hideInPanel: true })
]
const { widgetsSectionDataList } = computedSectionDataList([node])
const shownNames = widgetsSectionDataList.value[0].widgets.map(
({ widget }) => widget.name
)
expect(shownNames).toEqual(['seed'])
})
})
describe('flatAndCategorizeSelectedItems', () => {
let testGroup1: LGraphGroup
let testGroup2: LGraphGroup

View File

@@ -263,6 +263,7 @@ export function computedSectionDataList(nodes: MaybeRefOrGetter<LGraphNode[]>) {
!(
w.options?.canvasOnly ||
w.options?.hidden ||
w.options?.hideInPanel ||
(w.options?.advanced && !includesAdvanced.value)
)
)

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

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

@@ -43,7 +43,7 @@ const CHANNEL_INDEX: Record<ChannelMode, number> = {
luminance: 5
}
export interface PixelReadout {
interface PixelReadout {
x: number
y: number
r: number

View File

@@ -8,7 +8,7 @@ import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { NodeId } from '@/types/nodeId'
import { resolveNode } from '@/utils/litegraphUtil'
export type ResizeDirection =
type ResizeDirection =
| 'top'
| 'bottom'
| 'left'
@@ -18,7 +18,7 @@ export type ResizeDirection =
| 'sw'
| 'se'
export interface ResizeHandle {
interface ResizeHandle {
direction: ResizeDirection
class: string
style: {

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

@@ -7,7 +7,7 @@ import type {
import { getActionbarDockState } from '@/platform/telemetry/utils/getActionbarDockState'
import { getExecutionContext } from '@/platform/telemetry/utils/getExecutionContext'
export type RunButtonTelemetryOptions = {
type RunButtonTelemetryOptions = {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}

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

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

@@ -343,7 +343,7 @@ useExtensionService().registerExtension({
name: inputName,
component: Load3D,
inputSpec: { ...inputSpecLoad3D, name: inputName },
options: {}
options: { hideInPanel: true }
})
widget.type = 'load3D'
@@ -566,7 +566,7 @@ useExtensionService().registerExtension({
name: inputSpecPreview3D.name,
component: Load3D,
inputSpec: inputSpecPreview3D,
options: {}
options: { hideInPanel: true }
})
widget.type = 'load3D'

View File

@@ -45,7 +45,7 @@ useExtensionService().registerExtension({
name: 'viewport_state',
component: Load3DAdvanced,
inputSpec: inputSpecLoad3DAdvanced,
options: {}
options: { hideInPanel: true }
})
widget.type = 'load3DAdvanced'

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

@@ -107,7 +107,7 @@ useExtensionService().registerExtension({
name: inputSpec.name,
component: Load3D,
inputSpec,
options: {}
options: { hideInPanel: true }
})
widget.type = 'load3D'

View File

@@ -43,6 +43,14 @@ export interface IWidgetOptions<TValues = unknown> {
socketless?: boolean
/** If `true`, the widget will not be rendered by the Vue renderer. */
canvasOnly?: boolean
/**
* If `true`, the widget still renders on the node but is omitted from the
* right side panel. Unlike {@link IWidgetOptions.canvasOnly}, the node body
* keeps rendering it via the Vue renderer. Used for widgets that hold
* non-syncable state (e.g. a Three.js viewport) where a second instance in
* the panel would diverge from the one on the node.
*/
hideInPanel?: boolean
/** Used as a temporary override for determining the asset type in vue mode*/
nodeType?: string
@@ -138,6 +146,7 @@ export type IWidget =
| ICurveWidget
| IPainterWidget
| IRangeWidget
| IVideoTrimWidget
| IBoundingBoxesWidget
| IColorsWidget
@@ -361,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[]
@@ -379,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...",
@@ -3605,6 +3606,7 @@
"back": "Back",
"next": "Next",
"publishButton": "Publish to ComfyHub",
"updateButton": "Update workflow",
"examplesDescription": "Add up to {total} additional sample images",
"uploadAnImage": "Click to browse or drag an image",
"uploadExampleImage": "Upload example image",
@@ -3619,6 +3621,8 @@
"createProfileCta": "Create a profile",
"publishFailedTitle": "Publish failed",
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again.",
"renameFailedTitle": "Rename failed",
"renameFailedDescription": "Your workflow was published successfully, but renaming the local file failed. Rename it again to match.",
"publishSuccessTitle": "Published successfully",
"publishSuccessDescription": "Your workflow is now live on ComfyHub."
},
@@ -3627,9 +3631,12 @@
"profileCreationNav": "Profile creation",
"introTitle": "Publish to the ComfyHub",
"introDescription": "Publish your workflows, build your portfolio and get discovered by millions of users",
"updateIntroTitle": "Update your ComfyHub workflow",
"updateIntroDescription": "Push your latest changes to ComfyHub. Your share link and stats stay the same.",
"introSubtitle": "To share your workflow on ComfyHub, let's first create your profile.",
"createProfileButton": "Create my profile",
"startPublishingButton": "Start publishing",
"startUpdatingButton": "Update workflow",
"modalTitle": "Create your profile on ComfyHub",
"createProfileTitle": "Create your Comfy Hub profile",
"uploadCover": "+ Upload a cover",
@@ -4471,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

@@ -17,7 +17,7 @@ export interface ResolveModelNodeError {
details?: Record<string, unknown>
}
export interface ResolvedModelNode {
interface ResolvedModelNode {
provider: ModelNodeProvider
filename: string
}

View File

@@ -1,7 +1,7 @@
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
import { useDialogService } from '@/services/dialogService'
export interface AccountPreconditionContext {
interface AccountPreconditionContext {
/** Node type that triggered the precondition, used as modal context. */
nodeType?: string
}

View File

@@ -17,7 +17,7 @@ export type SubscriptionDialogReason =
| 'top_up_blocked'
| 'deep_link'
export interface SubscriptionDialogOptions {
interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason
/**
* Forces the unified pricing dialog to open on a specific plan tab,

View File

@@ -1,4 +1,4 @@
export interface MonthlyCreditsUsage {
interface MonthlyCreditsUsage {
/** Credits consumed from the monthly allowance (never negative). */
used: number
/** Fraction (01) of the monthly allowance consumed — drives the bar fill. */

View File

@@ -2,7 +2,7 @@ import { sumBy } from 'es-toolkit'
import type { MissingMediaGroup, MissingMediaViewModel } from './types'
export interface MissingMediaReference {
interface MissingMediaReference {
mediaItem: MissingMediaViewModel
nodeRef: MissingMediaViewModel['referencingNodes'][number]
}

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

@@ -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()
})
})
})

View File

@@ -6,6 +6,8 @@ import { compare, valid } from 'semver'
import { ref } from 'vue'
import type { SettingParams } from '@/platform/settings/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SettingChangedMetadata } from '@/platform/telemetry/types'
import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -23,6 +25,11 @@ export interface SettingTreeNode extends TreeNode {
data?: SettingParams
}
interface AppliedSetting<TValue> {
previousValue: TValue
newValue: TValue
}
function tryMigrateDeprecatedValue(
setting: SettingParams | undefined,
value: unknown
@@ -45,6 +52,28 @@ function onChange(
}
}
function settingChangedEvent<K extends keyof Settings>(
setting: SettingParams | undefined,
key: K,
applied: AppliedSetting<Settings[K]>
): SettingChangedMetadata | undefined {
if (!setting) return undefined
const telemetry = setting.telemetry
const isVisible = setting.type !== 'hidden'
const trackChanges = telemetry?.trackChanges ?? isVisible
if (!trackChanges) return undefined
const includeValues = telemetry?.includeValues ?? isVisible
return includeValues
? {
setting_id: key,
previous_value: applied.previousValue,
new_value: applied.newValue
}
: { setting_id: key }
}
export const useSettingStore = defineStore('setting', () => {
const settingValues = ref<Partial<Settings>>({})
const settingsById = ref<Record<string, SettingParams>>({})
@@ -99,7 +128,7 @@ export const useSettingStore = defineStore('setting', () => {
function applySettingLocally<K extends keyof Settings>(
key: K,
value: Settings[K]
): Settings[K] | undefined {
): AppliedSetting<Settings[K]> | undefined {
const clonedValue = _.cloneDeep(value)
const newValue = tryMigrateDeprecatedValue(
settingsById.value[key],
@@ -109,8 +138,12 @@ export const useSettingStore = defineStore('setting', () => {
if (newValue === oldValue) return undefined
onChange(settingsById.value[key], newValue, oldValue)
settingValues.value[key] = newValue
return newValue as Settings[K]
const typedNewValue = newValue as Settings[K]
settingValues.value[key] = typedNewValue
return {
previousValue: oldValue,
newValue: typedNewValue
}
}
/**
@@ -121,7 +154,10 @@ export const useSettingStore = defineStore('setting', () => {
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
const applied = applySettingLocally(key, value)
if (applied === undefined) return
await api.storeSetting(key, applied)
await api.storeSetting(key, applied.newValue)
const event = settingChangedEvent(settingsById.value[key], key, applied)
if (event) useTelemetry()?.trackSettingChanged(event)
}
/**
@@ -130,6 +166,7 @@ export const useSettingStore = defineStore('setting', () => {
*/
async function setMany(settings: Partial<Settings>) {
const updatedSettings: Partial<Settings> = {}
const telemetryEvents: SettingChangedMetadata[] = []
for (const key of Object.keys(settings) as (keyof Settings)[]) {
const applied = applySettingLocally(
@@ -137,12 +174,18 @@ export const useSettingStore = defineStore('setting', () => {
settings[key] as Settings[typeof key]
)
if (applied !== undefined) {
updatedSettings[key] = applied
updatedSettings[key] = applied.newValue
const event = settingChangedEvent(settingsById.value[key], key, applied)
if (event) telemetryEvents.push(event)
}
}
if (Object.keys(updatedSettings).length > 0) {
await api.storeSettings(updatedSettings)
const telemetry = useTelemetry()
for (const event of telemetryEvents) {
telemetry?.trackSettingChanged(event)
}
}
}

View File

@@ -26,11 +26,22 @@ export interface SettingOption {
value?: string | number
}
type SettingTelemetryOptions =
| {
trackChanges: false
includeValues?: never
}
| {
trackChanges?: true
includeValues?: boolean
}
export interface SettingParams<TValue = unknown> extends FormItem {
id: keyof Settings
defaultValue: TValue | (() => TValue)
defaultsByInstallVersion?: Record<`${number}.${number}.${number}`, TValue>
onChange?(newValue: TValue, oldValue?: TValue): void
telemetry?: SettingTelemetryOptions
// By default category is id.split('.'). However, changing id to assign
// new category has poor backward compatibility. Use this field to overwrite
// default category from id.

View File

@@ -109,7 +109,7 @@ describe('PostHogTelemetryProvider', () => {
api_host: 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageview: 'history_change',
capture_pageleave: false,
persistence: 'localStorage+cookie'
})
@@ -635,7 +635,7 @@ describe('PostHogTelemetryProvider', () => {
})
describe('page view', () => {
it('captures page view with page_name property', async () => {
it('captures legacy page view event with page_name property', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
@@ -645,9 +645,13 @@ describe('PostHogTelemetryProvider', () => {
TelemetryEvents.PAGE_VIEW,
{ page_name: 'workflow_editor' }
)
expect(hoisted.mockCapture).not.toHaveBeenCalledWith(
'$pageview',
expect.anything()
)
})
it('forwards additional metadata', async () => {
it('forwards additional metadata to legacy page view event', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
@@ -660,6 +664,20 @@ describe('PostHogTelemetryProvider', () => {
{ page_name: 'workflow_editor', path: '/workflows/123' }
)
})
it('queues legacy page view event before initialization', async () => {
const provider = createProvider()
provider.trackPageView('workflow_editor')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
await vi.dynamicImportSettled()
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.PAGE_VIEW,
{ page_name: 'workflow_editor' }
)
})
})
describe('before_send', () => {

View File

@@ -126,7 +126,7 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageview: 'history_change',
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',

View File

@@ -176,6 +176,7 @@
<ComfyHubPublishIntroPanel
v-else
data-testid="publish-intro"
:is-update="!!publishResult"
:on-create-profile="handleOpenPublishDialog"
:on-close="onClose"
:show-close-button="false"

View File

@@ -15,10 +15,18 @@
<!-- Content -->
<section class="flex flex-col items-center gap-4 px-4 pt-4 pb-6">
<h2 class="m-0 text-base font-semibold text-base-foreground">
{{ $t('comfyHubProfile.introTitle') }}
{{
isUpdate
? $t('comfyHubProfile.updateIntroTitle')
: $t('comfyHubProfile.introTitle')
}}
</h2>
<p class="m-0 text-center text-sm text-muted-foreground">
{{ $t('comfyHubProfile.introDescription') }}
{{
isUpdate
? $t('comfyHubProfile.updateIntroDescription')
: $t('comfyHubProfile.introDescription')
}}
</p>
<Button
variant="primary"
@@ -26,7 +34,11 @@
class="mt-2 w-full"
@click="onCreateProfile"
>
{{ $t('comfyHubProfile.startPublishingButton') }}
{{
isUpdate
? $t('comfyHubProfile.startUpdatingButton')
: $t('comfyHubProfile.startPublishingButton')
}}
</Button>
</section>
</div>
@@ -38,10 +50,12 @@ import Button from '@/components/ui/button/Button.vue'
const {
onCreateProfile,
onClose,
showCloseButton = true
showCloseButton = true,
isUpdate = false
} = defineProps<{
onCreateProfile: () => void
onClose: () => void
showCloseButton?: boolean
isUpdate?: boolean
}>()
</script>

View File

@@ -1,7 +1,7 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { nextTick, ref } from 'vue'
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal()
@@ -30,6 +30,10 @@ const mockCachePublishPrefill = vi.hoisted(() => vi.fn())
const mockGetCachedPrefill = vi.hoisted(() => vi.fn())
const mockSubmitToComfyHub = vi.hoisted(() => vi.fn())
const mockGetPublishStatus = vi.hoisted(() => vi.fn())
const mockRenameWorkflow = vi.hoisted(() => vi.fn())
const mockFormDataHolder = vi.hoisted(
() => ({ value: null }) as { value: Record<string, unknown> | null }
)
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubProfileGate',
@@ -42,35 +46,41 @@ vi.mock(
vi.mock(
'@/platform/workflow/sharing/composables/useComfyHubPublishWizard',
() => ({
useComfyHubPublishWizard: () => ({
currentStep: ref('finish'),
formData: ref({
name: '',
description: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
exampleImages: [],
tutorialUrl: '',
metadata: {}
() => {
mockFormDataHolder.value = {
name: '',
description: '',
tags: [],
models: [],
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: null,
existingThumbnailType: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
comparisonAfterUrl: null,
exampleImages: [],
tutorialUrl: '',
metadata: {}
}
return {
useComfyHubPublishWizard: () => ({
currentStep: ref('finish'),
formData: ref(mockFormDataHolder.value),
isFirstStep: ref(false),
isLastStep: ref(true),
goToStep: mockGoToStep,
goNext: mockGoNext,
goBack: mockGoBack,
openProfileCreationStep: mockOpenProfileCreationStep,
closeProfileCreationStep: mockCloseProfileCreationStep,
applyPrefill: mockApplyPrefill
}),
isFirstStep: ref(false),
isLastStep: ref(true),
goToStep: mockGoToStep,
goNext: mockGoNext,
goBack: mockGoBack,
openProfileCreationStep: mockOpenProfileCreationStep,
closeProfileCreationStep: mockCloseProfileCreationStep,
applyPrefill: mockApplyPrefill
}),
cachePublishPrefill: mockCachePublishPrefill,
getCachedPrefill: mockGetCachedPrefill
})
cachePublishPrefill: mockCachePublishPrefill,
getCachedPrefill: mockGetCachedPrefill
}
}
)
vi.mock(
@@ -90,23 +100,44 @@ vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
renameWorkflow: vi.fn(),
renameWorkflow: mockRenameWorkflow,
saveWorkflow: vi.fn()
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
const mockWorkflowStore = vi.hoisted(() => {
return {
instance: null as { activeWorkflow: Record<string, unknown> | null } | null
}
})
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const { reactive } = await import('vue')
mockWorkflowStore.instance = reactive({
activeWorkflow: {
path: 'workflows/test.json',
filename: 'test.json',
directory: 'workflows',
isTemporary: false,
isModified: false
},
saveWorkflow: vi.fn()
} as Record<string, unknown> | null
})
}))
return {
useWorkflowStore: () => ({
...mockWorkflowStore.instance,
get activeWorkflow() {
return mockWorkflowStore.instance?.activeWorkflow ?? null
},
saveWorkflow: vi.fn()
})
}
})
function setActiveWorkflow(workflow: Record<string, unknown> | null) {
if (mockWorkflowStore.instance) {
mockWorkflowStore.instance.activeWorkflow = workflow
}
}
async function flushPromises() {
await new Promise((r) => setTimeout(r, 0))
@@ -117,8 +148,17 @@ describe('ComfyHubPublishDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
setActiveWorkflow({
path: 'workflows/test.json',
filename: 'test.json',
directory: 'workflows',
isTemporary: false,
isModified: false
})
mockFetchProfile.mockResolvedValue(null)
mockSubmitToComfyHub.mockResolvedValue(undefined)
mockRenameWorkflow.mockResolvedValue(undefined)
if (mockFormDataHolder.value) mockFormDataHolder.value.name = ''
mockGetCachedPrefill.mockReturnValue(null)
mockGetPublishStatus.mockResolvedValue({
isPublished: false,
@@ -226,6 +266,119 @@ describe('ComfyHubPublishDialog', () => {
expect(onClose).toHaveBeenCalledOnce()
})
it('renames the local workflow when the published name differs', async () => {
renderComponent()
await flushPromises()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
await userEvent.click(screen.getByTestId('publish'))
await flushPromises()
expect(mockRenameWorkflow).toHaveBeenCalledWith(
expect.anything(),
'workflows/renamed.json'
)
expect(mockSubmitToComfyHub.mock.invocationCallOrder[0]).toBeLessThan(
mockRenameWorkflow.mock.invocationCallOrder[0]
)
})
it('does not rename when the published name matches the file name', async () => {
renderComponent()
await flushPromises()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'test'
await userEvent.click(screen.getByTestId('publish'))
await flushPromises()
expect(mockRenameWorkflow).not.toHaveBeenCalled()
})
it('still reports success but warns when the post-publish rename fails', async () => {
mockRenameWorkflow.mockRejectedValueOnce(new Error('rename failed'))
renderComponent()
await flushPromises()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
await userEvent.click(screen.getByTestId('publish'))
await flushPromises()
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'warn' })
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
expect(onClose).toHaveBeenCalledOnce()
})
it('does not rename or close when publish submission fails', async () => {
mockSubmitToComfyHub.mockRejectedValueOnce(new Error('submit failed'))
renderComponent()
await flushPromises()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
await userEvent.click(screen.getByTestId('publish'))
await flushPromises()
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
expect(mockRenameWorkflow).not.toHaveBeenCalled()
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'error' })
)
expect(mockToastAdd).not.toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
expect(onClose).not.toHaveBeenCalled()
})
it('does not refetch publish status when the rename changes the path mid-publish', async () => {
mockRenameWorkflow.mockImplementationOnce(async () => {
setActiveWorkflow({
path: 'workflows/renamed.json',
filename: 'renamed.json',
directory: 'workflows',
isTemporary: false,
isModified: false
})
})
renderComponent()
await flushPromises()
mockGetPublishStatus.mockClear()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
await userEvent.click(screen.getByTestId('publish'))
await flushPromises()
expect(mockGetPublishStatus).not.toHaveBeenCalledWith(
'workflows/renamed.json'
)
})
it('caches the prefill under the renamed path after publish', async () => {
mockRenameWorkflow.mockImplementationOnce(async () => {
setActiveWorkflow({
path: 'workflows/renamed.json',
filename: 'renamed.json',
directory: 'workflows',
isTemporary: false,
isModified: false
})
})
renderComponent()
await flushPromises()
if (mockFormDataHolder.value) mockFormDataHolder.value.name = 'renamed'
await userEvent.click(screen.getByTestId('publish'))
await flushPromises()
expect(mockCachePublishPrefill).toHaveBeenCalledWith(
'workflows/renamed.json',
expect.anything()
)
})
it('applies prefill when workflow is already published with metadata', async () => {
mockGetPublishStatus.mockResolvedValue({
isPublished: true,
@@ -282,4 +435,95 @@ describe('ComfyHubPublishDialog', () => {
expect(mockApplyPrefill).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
})
it('falls back to cached prefill when the status fetch fails', async () => {
mockGetPublishStatus.mockRejectedValue(new Error('Network error'))
const cached = { description: 'cached' }
mockGetCachedPrefill.mockReturnValue(cached)
renderComponent()
await flushPromises()
expect(mockApplyPrefill).toHaveBeenCalledWith(cached)
})
it('refetches prefill when the active workflow path changes (e.g. rename)', async () => {
renderComponent()
await flushPromises()
expect(mockGetPublishStatus).toHaveBeenLastCalledWith('workflows/test.json')
mockGetPublishStatus.mockClear()
setActiveWorkflow({
path: 'workflows/renamed.json',
filename: 'renamed.json',
directory: 'workflows',
isTemporary: false,
isModified: false
})
await nextTick()
await flushPromises()
expect(mockGetPublishStatus).toHaveBeenCalledWith('workflows/renamed.json')
})
it('does not refetch prefill when the active workflow path is unchanged', async () => {
renderComponent()
await flushPromises()
mockGetPublishStatus.mockClear()
setActiveWorkflow({
path: 'workflows/test.json',
filename: 'test.json',
directory: 'workflows',
isTemporary: false,
isModified: true
})
await nextTick()
await flushPromises()
expect(mockGetPublishStatus).not.toHaveBeenCalled()
})
it('ignores a stale prefill response after the workflow path changes', async () => {
const stalePrefill = { description: 'stale' }
let resolveStale: (value: unknown) => void = () => {}
mockGetPublishStatus.mockImplementation((path: string) => {
if (path === 'workflows/test.json') {
return new Promise((resolve) => {
resolveStale = resolve
})
}
return Promise.resolve({
isPublished: true,
shareId: 'fresh',
shareUrl: null,
publishedAt: new Date(),
prefill: { description: 'fresh' }
})
})
renderComponent()
await nextTick()
setActiveWorkflow({
path: 'workflows/renamed.json',
filename: 'renamed.json',
directory: 'workflows',
isTemporary: false,
isModified: false
})
await nextTick()
await flushPromises()
resolveStale({
isPublished: true,
shareId: 'stale',
shareUrl: null,
publishedAt: new Date(),
prefill: stalePrefill
})
await flushPromises()
expect(mockApplyPrefill).not.toHaveBeenCalledWith(stalePrefill)
})
})

View File

@@ -60,6 +60,7 @@
:is-first-step
:is-last-step
:is-publishing
:is-update="isAlreadyPublished"
:on-update-form-data="updateFormData"
:on-go-next="goNext"
:on-go-back="goBack"
@@ -129,6 +130,7 @@ const {
applyPrefill
} = useComfyHubPublishWizard()
const isPublishing = ref(false)
const isAlreadyPublished = ref(false)
const needsSave = ref(false)
const workflowName = ref('')
const nameInputRef = ref<InstanceType<typeof Input> | null>(null)
@@ -205,6 +207,27 @@ function handleRequireProfile() {
openProfileCreationStep()
}
async function syncWorkflowName(): Promise<void> {
const workflow = workflowStore.activeWorkflow
if (!workflow || workflow.isTemporary) return
const desiredName = formData.value.name.trim().replace(/\.json$/i, '')
const currentName = workflow.filename.replace(/\.json$/i, '')
if (!desiredName || desiredName === currentName) return
const newPath = buildWorkflowPath(workflow.directory, desiredName)
try {
await workflowService.renameWorkflow(workflow, newPath)
} catch (error) {
console.error('Failed to rename workflow after publish:', error)
toast.add({
severity: 'warn',
summary: t('comfyHubPublish.renameFailedTitle'),
detail: t('comfyHubPublish.renameFailedDescription')
})
}
}
async function handlePublish(): Promise<void> {
if (isPublishing.value) {
return
@@ -213,6 +236,7 @@ async function handlePublish(): Promise<void> {
isPublishing.value = true
try {
await submitToComfyHub(formData.value)
await syncWorkflowName()
const path = workflowStore.activeWorkflow?.path
if (path) {
cachePublishPrefill(path, formData.value)
@@ -242,10 +266,15 @@ function updateFormData(patch: Partial<ComfyHubPublishFormData>) {
async function fetchPublishPrefill() {
const path = workflowStore.activeWorkflow?.path
if (!path) return
if (!path) {
isAlreadyPublished.value = false
return
}
try {
const status = await shareService.getPublishStatus(path)
if (workflowStore.activeWorkflow?.path !== path) return
isAlreadyPublished.value = status.isPublished
const prefill = status.isPublished
? (status.prefill ?? getCachedPrefill(path))
: getCachedPrefill(path)
@@ -253,6 +282,8 @@ async function fetchPublishPrefill() {
applyPrefill(prefill)
}
} catch (error) {
if (workflowStore.activeWorkflow?.path !== path) return
isAlreadyPublished.value = false
console.warn('Failed to fetch publish prefill:', error)
const cached = getCachedPrefill(path)
if (cached) {
@@ -267,6 +298,15 @@ onMounted(() => {
void fetchPublishPrefill()
})
watch(
() => workflowStore.activeWorkflow?.path,
(newPath, oldPath) => {
if (isPublishing.value) return
if (!newPath || newPath === oldPath) return
void fetchPublishPrefill()
}
)
onBeforeUnmount(() => {
for (const image of formData.value.exampleImages) {
if (image.file) {

View File

@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it } from 'vitest'
import ComfyHubPublishFooter from './ComfyHubPublishFooter.vue'
function renderFooter(props: Record<string, unknown> = {}) {
return render(ComfyHubPublishFooter, {
props: { isFirstStep: false, isLastStep: true, ...props },
global: {
mocks: { $t: (key: string) => key },
stubs: {
Button: {
template: '<button><slot /></button>'
}
}
}
})
}
describe('ComfyHubPublishFooter', () => {
it('shows the publish label for a new workflow', () => {
renderFooter({ isUpdate: false })
expect(screen.getByText('comfyHubPublish.publishButton')).toBeTruthy()
})
it('shows the update label when the workflow is already published', () => {
renderFooter({ isUpdate: true })
expect(screen.getByText('comfyHubPublish.updateButton')).toBeTruthy()
})
})

View File

@@ -23,13 +23,26 @@
:loading="isPublishing"
@click="$emit('publish')"
>
<i class="icon-[lucide--upload] size-4" />
{{ $t('comfyHubPublish.publishButton') }}
<i
:class="
cn(
'size-4',
isUpdate ? 'icon-[lucide--refresh-cw]' : 'icon-[lucide--upload]'
)
"
/>
{{
isUpdate
? $t('comfyHubPublish.updateButton')
: $t('comfyHubPublish.publishButton')
}}
</Button>
</footer>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
defineProps<{
@@ -37,6 +50,7 @@ defineProps<{
isLastStep: boolean
isPublishDisabled?: boolean
isPublishing?: boolean
isUpdate?: boolean
}>()
defineEmits<{

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