Compare commits

..

12 Commits

Author SHA1 Message Date
huang47
29c876ec33 test: add origin CI guard smoke test 2026-06-29 20:30:44 -07:00
huang47
027aabc9e3 ci: skip chromatic deploy for fork PRs 2026-06-29 19:16:46 -07:00
huang47
9bb13dca52 ci: skip cloud cleanup dispatch for fork PRs 2026-06-29 19:16:38 -07:00
huang47
28a4881fdf ci: skip cloud build dispatch for fork PRs 2026-06-29 19:15:37 -07:00
huang47
1e655f44c2 ci: skip vercel preview deploy for fork PRs 2026-06-29 19:15:15 -07:00
huang47
8f216f15e3 ci: skip website e2e report deploy for fork PRs 2026-06-29 15:20:51 -07: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
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
78 changed files with 2425 additions and 143 deletions

View File

@@ -95,6 +95,7 @@ jobs:
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'

View File

@@ -30,7 +30,7 @@ concurrency:
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
permissions:
contents: read

View File

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

View File

@@ -32,12 +32,13 @@ jobs:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
(github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
runs-on: ubuntu-latest
steps:
- name: Build client payload

View File

@@ -21,6 +21,7 @@ jobs:
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||

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

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

@@ -0,0 +1 @@
PR 13291 origin CI guard smoke test.

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

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

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

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

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

View File

@@ -3605,6 +3605,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 +3620,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 +3630,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",

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

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

View File

@@ -52,8 +52,11 @@ function createDefaultFormData(): ComfyHubPublishFormData {
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: null,
existingThumbnailType: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
comparisonAfterUrl: null,
exampleImages: [],
tutorialUrl: '',
metadata: {}

View File

@@ -24,14 +24,24 @@
>
<ComfyHubThumbnailStep
:thumbnail-type="formData.thumbnailType"
:thumbnail-file="formData.thumbnailFile"
:thumbnail-url="formData.thumbnailUrl"
:existing-thumbnail-type="formData.existingThumbnailType"
:comparison-before-file="formData.comparisonBeforeFile"
:comparison-after-file="formData.comparisonAfterFile"
:comparison-after-url="formData.comparisonAfterUrl"
@update:thumbnail-type="onUpdateFormData({ thumbnailType: $event })"
@update:thumbnail-file="onUpdateFormData({ thumbnailFile: $event })"
@update:thumbnail-url="onUpdateFormData({ thumbnailUrl: $event })"
@update:comparison-before-file="
onUpdateFormData({ comparisonBeforeFile: $event })
"
@update:comparison-after-file="
onUpdateFormData({ comparisonAfterFile: $event })
"
@update:comparison-after-url="
onUpdateFormData({ comparisonAfterUrl: $event })
"
/>
<ComfyHubExamplesStep
:example-images="formData.exampleImages"
@@ -61,6 +71,7 @@
:is-last-step
:is-publish-disabled
:is-publishing="isPublishInFlight"
:is-update
@back="onGoBack"
@next="onGoNext"
@publish="handlePublish"
@@ -92,6 +103,7 @@ const {
isFirstStep,
isLastStep,
isPublishing = false,
isUpdate = false,
onGoNext,
onGoBack,
onUpdateFormData,
@@ -105,6 +117,7 @@ const {
isFirstStep: boolean
isLastStep: boolean
isPublishing?: boolean
isUpdate?: boolean
onGoNext: () => void
onGoBack: () => void
onUpdateFormData: (patch: Partial<ComfyHubPublishFormData>) => void

View File

@@ -0,0 +1,248 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as Record<string, unknown>),
useI18n: () => ({ t: (key: string) => key })
}
})
import type { ThumbnailType } from '@/platform/workflow/sharing/types/comfyHubTypes'
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
function renderStep(
props: Record<string, unknown> = {},
callbacks: Record<string, ReturnType<typeof vi.fn>> = {}
) {
return render(ComfyHubThumbnailStep, {
props: { thumbnailType: 'image' as ThumbnailType, ...props, ...callbacks },
global: {
mocks: { $t: (key: string) => key },
stubs: {
ToggleGroup: {
template:
'<div><button data-testid="type-image" @click="$emit(\'update:modelValue\', \'image\')" /><button data-testid="type-video" @click="$emit(\'update:modelValue\', \'video\')" /><button data-testid="type-comparison" @click="$emit(\'update:modelValue\', \'imageComparison\')" /><slot /></div>'
},
ToggleGroupItem: { template: '<div><slot /></div>', props: ['value'] },
Button: {
template:
'<button data-testid="clear-button" @click="$emit(\'click\')"><slot /></button>'
}
}
}
})
}
describe('ComfyHubThumbnailStep', () => {
it('shows the existing image thumbnail on the image tab', () => {
renderStep({
thumbnailType: 'image',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
existingThumbnailType: 'image'
})
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://cdn.example.com/thumb.png'
)
})
it('does not show an existing image thumbnail on the video tab', () => {
renderStep({
thumbnailType: 'video',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
existingThumbnailType: 'image'
})
// The image must not leak into the video tab as a preview; the upload
// prompt stays visible instead.
expect(screen.queryByRole('img')).toBeNull()
expect(
screen.getByText('comfyHubPublish.uploadPromptClickToBrowse')
).toBeTruthy()
})
it('keeps the existing thumbnail URL when the type changes', async () => {
const user = userEvent.setup()
const onUpdateThumbnailUrl = vi.fn()
const onUpdateThumbnailFile = vi.fn()
const onUpdateThumbnailType = vi.fn()
renderStep(
{
thumbnailType: 'image',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
existingThumbnailType: 'image'
},
{
'onUpdate:thumbnailUrl': onUpdateThumbnailUrl,
'onUpdate:thumbnailFile': onUpdateThumbnailFile,
'onUpdate:thumbnailType': onUpdateThumbnailType
}
)
await user.click(screen.getByTestId('type-video'))
expect(onUpdateThumbnailType).toHaveBeenCalledWith('video')
// The uploaded file is cleared, but the existing URL is preserved so
// toggling back restores the preview.
expect(onUpdateThumbnailFile).toHaveBeenCalledWith(null)
expect(onUpdateThumbnailUrl).not.toHaveBeenCalled()
})
it('keeps restored comparison URLs when switching away from the comparison type', async () => {
const user = userEvent.setup()
const onUpdateThumbnailUrl = vi.fn()
const onUpdateComparisonAfterUrl = vi.fn()
const onUpdateComparisonBeforeFile = vi.fn()
const onUpdateComparisonAfterFile = vi.fn()
const onUpdateThumbnailType = vi.fn()
renderStep(
{
thumbnailType: 'imageComparison',
thumbnailUrl: 'https://cdn.example.com/before.png',
comparisonAfterUrl: 'https://cdn.example.com/after.png',
existingThumbnailType: 'imageComparison'
},
{
'onUpdate:thumbnailUrl': onUpdateThumbnailUrl,
'onUpdate:comparisonAfterUrl': onUpdateComparisonAfterUrl,
'onUpdate:comparisonBeforeFile': onUpdateComparisonBeforeFile,
'onUpdate:comparisonAfterFile': onUpdateComparisonAfterFile,
'onUpdate:thumbnailType': onUpdateThumbnailType
}
)
await user.click(screen.getByTestId('type-image'))
expect(onUpdateThumbnailType).toHaveBeenCalledWith('image')
// Comparison file inputs reset, but the restored before/after URLs stay
// inert so switching back restores the previews.
expect(onUpdateComparisonBeforeFile).toHaveBeenCalledWith(null)
expect(onUpdateComparisonAfterFile).toHaveBeenCalledWith(null)
expect(onUpdateThumbnailUrl).not.toHaveBeenCalled()
expect(onUpdateComparisonAfterUrl).not.toHaveBeenCalled()
})
it('renders a restored GIF thumbnail as an image, not a video', () => {
const { container } = renderStep({
thumbnailType: 'video',
thumbnailUrl: 'https://cdn.example.com/anim.gif',
existingThumbnailType: 'video'
})
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'https://cdn.example.com/anim.gif'
)
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
expect(container.querySelector('video')).toBeNull()
})
it('renders a restored mp4 thumbnail as a video', () => {
const { container } = renderStep({
thumbnailType: 'video',
thumbnailUrl: 'https://cdn.example.com/clip.mp4',
existingThumbnailType: 'video'
})
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
const video = container.querySelector('video')
expect(video?.getAttribute('src')).toBe('https://cdn.example.com/clip.mp4')
expect(screen.queryByRole('img')).toBeNull()
})
it('renders a restored extensionless video-mode thumbnail as a video', () => {
const { container } = renderStep({
thumbnailType: 'video',
thumbnailUrl: 'https://cdn.example.com/assets/object-key',
existingThumbnailType: 'video'
})
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
expect(container.querySelector('video')).not.toBeNull()
expect(screen.queryByRole('img')).toBeNull()
})
it('renders a restored video-mode thumbnail with a query string as a video', () => {
const { container } = renderStep({
thumbnailType: 'video',
thumbnailUrl: 'https://cdn.example.com/clip?token=abc123',
existingThumbnailType: 'video'
})
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
expect(container.querySelector('video')).not.toBeNull()
expect(screen.queryByRole('img')).toBeNull()
})
it('restores both comparison images on the comparison tab', () => {
const { container } = renderStep({
thumbnailType: 'imageComparison',
thumbnailUrl: 'https://cdn.example.com/before.png',
comparisonAfterUrl: 'https://cdn.example.com/after.png',
existingThumbnailType: 'imageComparison'
})
// eslint-disable-next-line testing-library/no-node-access, testing-library/no-container
const srcs = Array.from(container.querySelectorAll('img')).map((el) =>
el.getAttribute('src')
)
expect(srcs).toContain('https://cdn.example.com/before.png')
expect(srcs).toContain('https://cdn.example.com/after.png')
})
it('clears a restored image thumbnail when removed', async () => {
const user = userEvent.setup()
const onUpdateThumbnailFile = vi.fn()
const onUpdateThumbnailUrl = vi.fn()
renderStep(
{
thumbnailType: 'image',
thumbnailUrl: 'https://cdn.example.com/thumb.png',
existingThumbnailType: 'image'
},
{
'onUpdate:thumbnailFile': onUpdateThumbnailFile,
'onUpdate:thumbnailUrl': onUpdateThumbnailUrl
}
)
await user.click(screen.getByTestId('clear-button'))
expect(onUpdateThumbnailFile).toHaveBeenCalledWith(null)
expect(onUpdateThumbnailUrl).toHaveBeenCalledWith(null)
})
it('clears both restored comparison images when removed', async () => {
const user = userEvent.setup()
const onUpdateThumbnailUrl = vi.fn()
const onUpdateComparisonAfterUrl = vi.fn()
const onUpdateComparisonBeforeFile = vi.fn()
const onUpdateComparisonAfterFile = vi.fn()
renderStep(
{
thumbnailType: 'imageComparison',
thumbnailUrl: 'https://cdn.example.com/before.png',
comparisonAfterUrl: 'https://cdn.example.com/after.png',
existingThumbnailType: 'imageComparison'
},
{
'onUpdate:thumbnailUrl': onUpdateThumbnailUrl,
'onUpdate:comparisonAfterUrl': onUpdateComparisonAfterUrl,
'onUpdate:comparisonBeforeFile': onUpdateComparisonBeforeFile,
'onUpdate:comparisonAfterFile': onUpdateComparisonAfterFile
}
)
await user.click(screen.getByTestId('clear-button'))
expect(onUpdateThumbnailUrl).toHaveBeenCalledWith(null)
expect(onUpdateComparisonAfterUrl).toHaveBeenCalledWith(null)
expect(onUpdateComparisonBeforeFile).toHaveBeenCalledWith(null)
expect(onUpdateComparisonAfterFile).toHaveBeenCalledWith(null)
})
})

View File

@@ -150,7 +150,7 @@
/>
<template v-if="thumbnailPreviewUrl">
<video
v-if="isVideoFile"
v-if="showVideoPreview"
:src="thumbnailPreviewUrl"
:aria-label="$t('comfyHubPublish.videoPreview')"
class="max-h-full max-w-full object-contain"
@@ -194,18 +194,34 @@ import {
} from '@/platform/workflow/sharing/utils/validateFileSize'
import { cn } from '@comfyorg/tailwind-utils'
import { useDropZone, useObjectUrl } from '@vueuse/core'
import { computed, reactive, ref, shallowRef } from 'vue'
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { thumbnailType = 'image' } = defineProps<{
const {
thumbnailType = 'image',
thumbnailFile = null,
thumbnailUrl = null,
existingThumbnailType = null,
comparisonBeforeFile = null,
comparisonAfterFile = null,
comparisonAfterUrl = null
} = defineProps<{
thumbnailType?: ThumbnailType
thumbnailFile?: File | null
thumbnailUrl?: string | null
existingThumbnailType?: ThumbnailType | null
comparisonBeforeFile?: File | null
comparisonAfterFile?: File | null
comparisonAfterUrl?: string | null
}>()
const emit = defineEmits<{
'update:thumbnailType': [value: ThumbnailType]
'update:thumbnailFile': [value: File | null]
'update:thumbnailUrl': [value: string | null]
'update:comparisonBeforeFile': [value: File | null]
'update:comparisonAfterFile': [value: File | null]
'update:comparisonAfterUrl': [value: string | null]
}>()
const { t } = useI18n()
@@ -216,8 +232,7 @@ function isThumbnailType(value: string): value is ThumbnailType {
function handleThumbnailTypeChange(value: unknown) {
if (typeof value === 'string' && isThumbnailType(value)) {
comparisonBeforeFile.value = null
comparisonAfterFile.value = null
// Keep existing URLs; they stay inert until the type matches again (see existingSingleUrl).
emit('update:thumbnailFile', null)
emit('update:comparisonBeforeFile', null)
emit('update:comparisonAfterFile', null)
@@ -256,30 +271,59 @@ const thumbnailOptions = [
icon: 'icon-[lucide--diff]'
}
]
const existingSingleUrl = computed(() =>
existingThumbnailType === thumbnailType
? (thumbnailUrl ?? undefined)
: undefined
)
// For imageComparison, thumbnailUrl is the "before" image (the backend's primary thumbnail_url).
const existingComparisonUrls = computed(() =>
existingThumbnailType === 'imageComparison'
? {
before: thumbnailUrl ?? undefined,
after: comparisonAfterUrl ?? undefined
}
: { before: undefined, after: undefined }
)
const thumbnailFile = shallowRef<File | null>(null)
const thumbnailPreviewUrl = useObjectUrl(thumbnailFile)
const isVideoFile = ref(false)
const thumbnailFileUrl = useObjectUrl(() => thumbnailFile ?? undefined)
const thumbnailPreviewUrl = computed(
() => thumbnailFileUrl.value ?? existingSingleUrl.value
)
function isAnimatedImageUrl(url: string): boolean {
return /\.(gif|webp)(\?|#|$)/i.test(url)
}
const showVideoPreview = computed(() => {
if (thumbnailFile) return thumbnailFile.type.startsWith('video/')
return thumbnailType === 'video' && !!existingSingleUrl.value
? !isAnimatedImageUrl(existingSingleUrl.value)
: false
})
function setThumbnailPreview(file: File) {
const maxSize = file.type.startsWith('video/')
? MAX_VIDEO_SIZE_MB
: MAX_IMAGE_SIZE_MB
if (isFileTooLarge(file, maxSize)) return
thumbnailFile.value = file
isVideoFile.value = file.type.startsWith('video/')
emit('update:thumbnailFile', file)
emit('update:thumbnailUrl', null)
}
const comparisonBeforeFile = shallowRef<File | null>(null)
const comparisonAfterFile = shallowRef<File | null>(null)
const comparisonPreviewUrls = reactive({
before: useObjectUrl(comparisonBeforeFile),
after: useObjectUrl(comparisonAfterFile)
})
const comparisonBeforeFileUrl = useObjectUrl(
() => comparisonBeforeFile ?? undefined
)
const comparisonAfterFileUrl = useObjectUrl(
() => comparisonAfterFile ?? undefined
)
const comparisonPreviewUrls = computed(() => ({
before: comparisonBeforeFileUrl.value ?? existingComparisonUrls.value.before,
after: comparisonAfterFileUrl.value ?? existingComparisonUrls.value.after
}))
const hasBothComparisonImages = computed(
() => !!(comparisonPreviewUrls.before && comparisonPreviewUrls.after)
() =>
!!(comparisonPreviewUrls.value.before && comparisonPreviewUrls.value.after)
)
const comparisonPreviewRef = ref<HTMLElement | null>(null)
@@ -287,22 +331,24 @@ const previewSliderPosition = useSliderFromMouse(comparisonPreviewRef)
const hasThumbnailContent = computed(() => {
if (thumbnailType === 'imageComparison') {
return !!(comparisonPreviewUrls.before || comparisonPreviewUrls.after)
return !!(
comparisonPreviewUrls.value.before || comparisonPreviewUrls.value.after
)
}
return !!thumbnailPreviewUrl.value
})
function clearAllPreviews() {
if (thumbnailType === 'imageComparison') {
comparisonBeforeFile.value = null
comparisonAfterFile.value = null
emit('update:comparisonBeforeFile', null)
emit('update:comparisonAfterFile', null)
emit('update:thumbnailUrl', null)
emit('update:comparisonAfterUrl', null)
return
}
thumbnailFile.value = null
emit('update:thumbnailFile', null)
emit('update:thumbnailUrl', null)
}
function handleFileSelect(event: Event) {
@@ -349,19 +395,15 @@ const comparisonSlots = [
}
]
const comparisonFiles: Record<ComparisonSlot, typeof comparisonBeforeFile> = {
before: comparisonBeforeFile,
after: comparisonAfterFile
}
function setComparisonPreview(file: File, slot: ComparisonSlot) {
if (isFileTooLarge(file, MAX_IMAGE_SIZE_MB)) return
comparisonFiles[slot].value = file
if (slot === 'before') {
emit('update:comparisonBeforeFile', file)
emit('update:thumbnailUrl', null)
return
}
emit('update:comparisonAfterFile', file)
emit('update:comparisonAfterUrl', null)
}
function handleComparisonSelect(event: Event, slot: ComparisonSlot) {

View File

@@ -58,8 +58,11 @@ function createFormData(
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: null,
existingThumbnailType: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
comparisonAfterUrl: null,
exampleImages: [],
tutorialUrl: '',
metadata: {},
@@ -147,6 +150,110 @@ describe('useComfyHubPublishSubmission', () => {
)
})
it('sends the existing thumbnail URL when no new file is attached', async () => {
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: 'https://cdn.example.com/existing-thumb.png',
existingThumbnailType: 'image'
})
)
expect(mockRequestAssetUploadUrl).not.toHaveBeenCalled()
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailTokenOrUrl: 'https://cdn.example.com/existing-thumb.png'
})
)
})
it('sends the existing comparison URLs when no new files are attached', async () => {
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'imageComparison',
thumbnailFile: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
thumbnailUrl: 'https://cdn.example.com/before.png',
comparisonAfterUrl: 'https://cdn.example.com/after.png',
existingThumbnailType: 'imageComparison'
})
)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailTokenOrUrl: 'https://cdn.example.com/before.png',
thumbnailComparisonTokenOrUrl: 'https://cdn.example.com/after.png'
})
)
})
it('does not submit an existing thumbnail URL after the type is switched away', async () => {
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'video',
thumbnailFile: null,
thumbnailUrl: 'https://cdn.example.com/existing-image.png',
existingThumbnailType: 'image'
})
)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailType: 'video',
thumbnailTokenOrUrl: undefined
})
)
})
it('prefers a newly uploaded thumbnail file over the existing URL', async () => {
const thumbnailFile = new File(['thumbnail'], 'new-thumb.png', {
type: 'image/png'
})
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'image',
thumbnailFile,
thumbnailUrl: 'https://cdn.example.com/existing-thumb.png',
existingThumbnailType: 'image'
})
)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailTokenOrUrl: 'token-1'
})
)
})
it('prefers a newly uploaded comparison-after file over the existing URL', async () => {
const afterFile = new File(['after'], 'after.png', { type: 'image/png' })
const { submitToComfyHub } = useComfyHubPublishSubmission()
await submitToComfyHub(
createFormData({
thumbnailType: 'imageComparison',
comparisonBeforeFile: null,
comparisonAfterFile: afterFile,
thumbnailUrl: 'https://cdn.example.com/before.png',
comparisonAfterUrl: 'https://cdn.example.com/after.png',
existingThumbnailType: 'imageComparison'
})
)
expect(mockPublishWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
thumbnailComparisonTokenOrUrl: 'token-1'
})
)
})
it('uploads all example images', async () => {
const file1 = new File(['img1'], 'img1.png', { type: 'image/png' })
const file2 = new File(['img2'], 'img2.png', { type: 'image/png' })

View File

@@ -74,14 +74,21 @@ export function useComfyHubPublishSubmission() {
await workflowShareService.getShareableAssets()
)
const keepsExistingThumbnail =
formData.existingThumbnailType === formData.thumbnailType
const thumbnailFile = resolveThumbnailFile(formData)
const thumbnailTokenOrUrl = thumbnailFile
? await uploadFileAndGetToken(thumbnailFile)
: undefined
: keepsExistingThumbnail
? (formData.thumbnailUrl ?? undefined)
: undefined
const thumbnailComparisonTokenOrUrl =
formData.thumbnailType === 'imageComparison' &&
formData.comparisonAfterFile
? await uploadFileAndGetToken(formData.comparisonAfterFile)
formData.thumbnailType === 'imageComparison'
? formData.comparisonAfterFile
? await uploadFileAndGetToken(formData.comparisonAfterFile)
: keepsExistingThumbnail
? (formData.comparisonAfterUrl ?? undefined)
: undefined
: undefined
const sampleImageTokensOrUrls =

View File

@@ -142,4 +142,60 @@ describe('useComfyHubPublishWizard', () => {
expect(currentStep.value).toBe('finish')
})
})
describe('applyPrefill', () => {
it('restores the existing thumbnail URL into the form', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
applyPrefill({ thumbnailUrl: 'https://cdn.example.com/thumb.png' })
expect(formData.value.thumbnailUrl).toBe(
'https://cdn.example.com/thumb.png'
)
})
it('restores the comparison-after URL into the form', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
applyPrefill({
thumbnailType: 'imageComparison',
thumbnailUrl: 'https://cdn.example.com/before.png',
thumbnailComparisonUrl: 'https://cdn.example.com/after.png'
})
expect(formData.value.thumbnailUrl).toBe(
'https://cdn.example.com/before.png'
)
expect(formData.value.comparisonAfterUrl).toBe(
'https://cdn.example.com/after.png'
)
expect(formData.value.existingThumbnailType).toBe('imageComparison')
})
it('does not overwrite a freshly attached thumbnail file with the prefill URL', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
const file = new File(['x'], 'thumb.png', { type: 'image/png' })
formData.value = { ...formData.value, thumbnailFile: file }
applyPrefill({ thumbnailUrl: 'https://cdn.example.com/thumb.png' })
expect(formData.value.thumbnailFile?.name).toBe('thumb.png')
expect(formData.value.thumbnailUrl).toBeNull()
})
it('restores description, tags, and sample images alongside the thumbnail', () => {
const { applyPrefill, formData } = useComfyHubPublishWizard()
applyPrefill({
description: 'Restored description',
tags: ['art'],
thumbnailUrl: 'https://cdn.example.com/thumb.png',
sampleImageUrls: ['https://cdn.example.com/sample.png']
})
expect(formData.value.description).toBe('Restored description')
expect(formData.value.tags).toEqual(['art'])
expect(formData.value.thumbnailUrl).toBe(
'https://cdn.example.com/thumb.png'
)
expect(formData.value.exampleImages).toHaveLength(1)
expect(formData.value.exampleImages[0].url).toBe(
'https://cdn.example.com/sample.png'
)
})
})
})

View File

@@ -32,8 +32,11 @@ function createDefaultFormData(): ComfyHubPublishFormData {
customNodes: [],
thumbnailType: 'image',
thumbnailFile: null,
thumbnailUrl: null,
existingThumbnailType: null,
comparisonBeforeFile: null,
comparisonAfterFile: null,
comparisonAfterUrl: null,
exampleImages: [],
tutorialUrl: '',
metadata: {}
@@ -51,6 +54,8 @@ function extractPrefillFromFormData(
description: formData.description || undefined,
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
thumbnailType: formData.thumbnailType,
thumbnailUrl: formData.thumbnailUrl ?? undefined,
thumbnailComparisonUrl: formData.comparisonAfterUrl ?? undefined,
sampleImageUrls: formData.exampleImages
.map((img) => img.url)
.filter((url) => !url.startsWith('blob:'))
@@ -95,6 +100,13 @@ export function useComfyHubPublishWizard() {
function applyPrefill(prefill: PublishPrefill) {
const defaults = createDefaultFormData()
const current = formData.value
const hasThumbnail = !!(current.thumbnailFile || current.thumbnailUrl)
const hasComparisonAfter = !!(
current.comparisonAfterFile || current.comparisonAfterUrl
)
const restoredThumbnailUrl = hasThumbnail
? current.thumbnailUrl
: (prefill.thumbnailUrl ?? current.thumbnailUrl)
formData.value = {
...current,
description:
@@ -109,6 +121,14 @@ export function useComfyHubPublishWizard() {
current.thumbnailType === defaults.thumbnailType
? (prefill.thumbnailType ?? current.thumbnailType)
: current.thumbnailType,
thumbnailUrl: restoredThumbnailUrl,
existingThumbnailType:
restoredThumbnailUrl && !current.thumbnailFile
? (prefill.thumbnailType ?? current.existingThumbnailType)
: current.existingThumbnailType,
comparisonAfterUrl: hasComparisonAfter
? current.comparisonAfterUrl
: (prefill.thumbnailComparisonUrl ?? current.comparisonAfterUrl),
exampleImages:
current.exampleImages.length === 0 && prefill.sampleImageUrls?.length
? createExampleImagesFromUrls(prefill.sampleImageUrls)

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import { zSharedWorkflowResponse } from '@/platform/workflow/sharing/schemas/shareSchemas'
import {
zHubWorkflowPrefillResponse,
zSharedWorkflowResponse
} from '@/platform/workflow/sharing/schemas/shareSchemas'
function makePayload(name: string) {
return {
@@ -52,3 +55,18 @@ describe('zSharedWorkflowResponse name sanitization', () => {
expect(result.name).toBe('spaced name')
})
})
describe('zHubWorkflowPrefillResponse tag tolerance', () => {
it('drops a malformed tag without discarding the rest of the prefill', () => {
const result = zHubWorkflowPrefillResponse.safeParse({
description: 'A cool workflow',
thumbnail_url: 'https://cdn.example.com/thumb.png',
tags: [{ name: 'art', display_name: 'Art' }, 'rawtag', { name: 'broken' }]
})
expect(result.success).toBe(true)
expect(result.data?.tags).toEqual(['Art', 'rawtag'])
expect(result.data?.description).toBe('A cool workflow')
expect(result.data?.thumbnail_url).toBe('https://cdn.example.com/thumb.png')
})
})

View File

@@ -10,9 +10,18 @@ export const zPublishRecordResponse = z.object({
assets: z.array(zAssetInfo).optional()
})
const zPrefillTag = z
.object({ name: z.string(), display_name: z.string() })
.transform((label) => label.display_name)
.or(z.string())
const zPrefillTagList = z
.array(zPrefillTag.optional().catch(undefined))
.transform((tags) => tags.filter((tag): tag is string => tag !== undefined))
export const zHubWorkflowPrefillResponse = z.object({
description: z.string().nullish(),
tags: z.array(z.string()).nullish(),
tags: zPrefillTagList.nullish(),
sample_image_urls: z.array(z.string()).nullish(),
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).nullish(),
thumbnail_url: z.string().nullish(),

View File

@@ -177,7 +177,10 @@ describe(useWorkflowShareService, () => {
if (path === '/hub/workflows/wf-prefill') {
return mockJsonResponse({
description: 'A cool workflow',
tags: ['art', 'upscale'],
tags: [
{ name: 'art', display_name: 'Art' },
{ name: 'upscale', display_name: 'Upscale' }
],
thumbnail_type: 'image_comparison',
sample_image_urls: ['https://example.com/img1.png']
})
@@ -192,7 +195,7 @@ describe(useWorkflowShareService, () => {
expect(status.isPublished).toBe(true)
expect(status.prefill).toEqual({
description: 'A cool workflow',
tags: ['art', 'upscale'],
tags: ['Art', 'Upscale'],
thumbnailType: 'imageComparison',
sampleImageUrls: ['https://example.com/img1.png']
})

View File

@@ -45,6 +45,8 @@ interface PrefillMetadataFields {
description?: string | null
tags?: string[] | null
thumbnail_type?: 'image' | 'video' | 'image_comparison' | null
thumbnail_url?: string | null
thumbnail_comparison_url?: string | null
sample_image_urls?: string[] | null
}
@@ -52,18 +54,29 @@ function extractPrefill(fields: PrefillMetadataFields): PublishPrefill | null {
const description = fields.description ?? undefined
const tags = fields.tags ?? undefined
const thumbnailType = mapApiThumbnailType(fields.thumbnail_type)
const thumbnailUrl = fields.thumbnail_url ?? undefined
const thumbnailComparisonUrl = fields.thumbnail_comparison_url ?? undefined
const sampleImageUrls = fields.sample_image_urls ?? undefined
if (
!description &&
!tags?.length &&
!thumbnailType &&
!thumbnailUrl &&
!thumbnailComparisonUrl &&
!sampleImageUrls?.length
) {
return null
}
return { description, tags, thumbnailType, sampleImageUrls }
return {
description,
tags,
thumbnailType,
thumbnailUrl,
thumbnailComparisonUrl,
sampleImageUrls
}
}
function decodeHubWorkflowPrefill(payload: unknown): PublishPrefill | null {

View File

@@ -14,8 +14,11 @@ export interface ComfyHubPublishFormData {
customNodes: string[]
thumbnailType: ThumbnailType
thumbnailFile: File | null
thumbnailUrl: string | null
existingThumbnailType: ThumbnailType | null
comparisonBeforeFile: File | null
comparisonAfterFile: File | null
comparisonAfterUrl: string | null
exampleImages: ExampleImage[]
tutorialUrl: string
metadata: Record<string, unknown>

View File

@@ -15,6 +15,8 @@ export interface PublishPrefill {
description?: string
tags?: string[]
thumbnailType?: ThumbnailType
thumbnailUrl?: string
thumbnailComparisonUrl?: string
sampleImageUrls?: string[]
}

View File

@@ -1,7 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export interface ResolvedHostWidget {
interface ResolvedHostWidget {
node: LGraphNode
widget: IBaseWidget
}