Compare commits

..

10 Commits

Author SHA1 Message Date
coderabbitai[bot]
4384b7d6bb CodeRabbit Generated Unit Tests: Add unit tests for PR changes 2026-06-11 07:06:49 +00:00
Comfy Org PR Bot
603914e78f 1.46.13 (#12779)
Patch version increment to 1.46.13

**Base branch:** `main`

---------

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-11 04:04:09 +00:00
jaeone94
c7797b201e Simplify swap node error presentation (#12768)
## Summary

Simplifies the Swap Nodes error card as the fourth slice of the
catalog/error-tab presentation refactor, aligning it with the newer
compact error-row patterns while preserving the existing replace and
locate behavior.

This follows the staged rollout plan from the earlier error-tab PRs:

1. #12683 refined execution-style errors: validation, runtime, and
prompt errors.
2. #12705 simplified missing media errors into flat, locatable rows.
3. #12735 simplified missing node pack errors and aligned grouped-row
behavior.
4. This PR applies the same simplification pass to Swap Nodes errors.
5. A later PR is expected to handle Missing Models, which is larger and
intentionally kept separate.

After the Missing Models slice lands, a follow-up consistency PR will
normalize the shared row/disclosure pattern across Missing Node Packs,
Swap Nodes, and Missing Models together. That follow-up will cover
parameterized i18n labels for disclosure controls, shared text-button
styling, and consistent disclosure semantics/accessibility across those
grouped rows.

## Changes

- **What**: Reworks the Swap Nodes card rows so each replacement group
is presented as a compact row with the source node type, replacement
target, replace action, and locate action.
- **What**: For a single affected node, the visible row label can be
clicked to locate the node, matching the interaction model used by the
newer missing-media and missing-node rows.
- **What**: For multiple affected nodes with the same replacement
target, the group renders a count badge and a disclosure row. Expanding
the group shows the affected node rows, each with its own locate action.
- **What**: Removes the old node-id badge path from Swap Nodes rows.
Node-id badges remain available to the other error cards that still own
that behavior.
- **What**: Keeps replacement behavior unchanged: per-group replacement
and replace-all still call through the existing node replacement store
flow.
- **What**: Adds regression coverage for the new grouped-row UI,
including same-type grouping in both Vue Nodes and LiteGraph render
modes.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Please focus on the Swap Nodes presentation and interaction symmetry
with the previous error-tab PRs:

- Single-node groups should remain directly locatable via the row label
and the locate icon.
- Multi-node groups should expose the count and expand/collapse behavior
without adding duplicate focusable disclosure controls.
- The visible row labels intentionally keep their own accessible names,
while the separate locate icon uses the generic `Locate node on canvas`
accessible name. This mirrors the established pattern from the previous
slices.
- The newly added Playwright fixture covers two same-type replaceable
nodes so duplicate group keys and grouped disclosure behavior are
exercised end-to-end.

## Validation

- `pnpm format`
- `pnpm test:unit
src/platform/nodeReplacement/components/SwapNodeGroupRow.test.ts`
- `pnpm test:browser:local browser_tests/tests/nodeReplacement.spec.ts
--project=chromium`
- Pre-commit hook: lint-staged, stylelint, oxfmt, oxlint, eslint, `pnpm
typecheck`, `pnpm typecheck:browser`
- Pre-push hook: `pnpm knip --cache`
- Additional parallel code review pass completed locally; no blocker or
major issues remained.

## Screenshots (if applicable)

This PR 

<img width="561" height="362" alt="스크린샷 2026-06-11 오전 3 46 06"
src="https://github.com/user-attachments/assets/65395467-6c2f-4aa1-84c5-3d9614c00c80"
/>

old (Main)
<img width="611" height="798" alt="스크린샷 2026-06-11 오전 3 46 32"
src="https://github.com/user-attachments/assets/3862d5df-f839-40c0-9488-ce64b051378e"
/>
2026-06-11 03:50:16 +00:00
Matt Miller
aa68573a6e fix: remove broken throttle from VirtualGrid scroll tracking (#12781)
## Summary

Every `VirtualGrid` consumer (assets sidebar, manager dialog, widget
select dropdown) is blind to discrete mouse-wheel scrolling:
`useScroll`'s `throttle: 64` never reports scroll position, so the
virtualization window stays frozen — users see blank space below the
first viewport of items and `approach-end` (infinite scroll) never
fires. Trackpad scrolling masks the bug by emitting events faster than
the throttle window.

## Changes

- **What**: drop the `throttle` option from `useScroll` in `VirtualGrid`
and remove the `scrollThrottle` prop (no consumer passes it). Scroll
events are frame-aligned and the handler is cheap, so the throttle
bought nothing even when it worked.
- **Root cause**: VueUse ≥14 `throttleFilter` with `leading=false` (what
`useScroll` uses) marks spaced-out events as executed without executing
them — each event re-arms an `isLeading` restore timer that makes the
next event skip its invoke, and the trailing branch is unreachable when
`elapsed > duration`. Regression of vueuse#2390; still present on vueuse
`main`.

## Review Focus

- Verified live against staging: with the throttle, sidebar scrolled to
`scrollTop` 1250 while `scrollY` stayed 0 and the render window stayed
at `[0..3)` of 27 (blank viewport); with this fix, `scrollY` tracks 1:1
and the window advances. Bare-vs-throttled `useScroll` compared
side-by-side on the same element to isolate the cause.
- Unblocks wheel-scroll for #12780's dropdown infinite scroll with no
changes there.

- Fixes FE-990

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-11 03:27:54 +00:00
Deep Mehta
79acf7be5e fix: re-encode favicon.ico with PNG frames to fix white corner artifacts (#12753)
## Summary

The rebranded `favicon.ico` renders with **opaque white corners** in
browsers — the rounded mark shows white slivers around its corners on
any background. This is a decode bug, not a design issue: the ICO
contains **BMP-format frames** whose alpha channel Chrome (and other
consumers) mishandle. Verified by loading the raw `.ico` in headless
Chrome on a dark page: corners render white instead of transparent.

Every surface that consumes the `.ico` directly shows the artifact —
Google search results, connector icon scraping, raw image views — while
browser tabs look fine because they prefer the SVG favicon.

## Changes

- **What**: Re-encode `apps/website/public/favicon.ico` and
`public/assets/favicon.ico` with **PNG-format ICO frames** (16/32/48).
PNG frames carry unambiguous alpha and decode correctly in all modern
browsers.
- **No design change**: identical rounded artwork, same transparency,
same sizes and filenames. SVG, PNGs, apple-touch-icon, and manifest
icons are untouched.
- **Breaking**: none.

## Review Focus

- Headless-Chrome verified: the old ico renders white corners on a dark
page; the re-encoded one renders transparent corners. (Comparison in PR
comments.)
- PNG-in-ICO is supported by all modern browsers and Google's favicon
pipeline.
- After merge, please add `needs-backport` + `cloud/1.45` so
cloud.comfy.org's copy gets the same fix.

## Screenshots (if applicable)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-11 03:27:53 +00:00
Robin Huang
02adfd4b83 feat: identify prompt source via comfy_usage_source extra_data (#12772)
Adds `comfy_usage_source: 'comfyui-frontend'` to the prompt body's
`extra_data`. The backend forwards this to API nodes' upstream requests
via the `Comfy-Usage-Source` header, so partner node API usage can be
attributed to the frontend.

Used in https://github.com/Comfy-Org/ComfyUI/pull/14404
2026-06-10 22:43:34 +00:00
Robin Huang
7c2c78b537 feat: send deploy_environment as Comfy-Env header on /releases requests (#12771)
Reads `system.deploy_environment` from `/system_stats` (added in
Comfy-Org/ComfyUI#14402) and sends it as the `Comfy-Env` header when
fetching `/releases`, matching the header name the backend already uses
for outbound API node requests. The header is omitted when the backend
doesn't report the field, so older backends are unaffected.

Note: api.comfy.org must allow `Comfy-Env` in
`Access-Control-Allow-Headers` for the CORS preflight to pass.
2026-06-10 21:30:08 +00:00
Matt Miller
bd1fd0680e feat(assets): walk getAllAssetsByTag via keyset cursor (#12720)
## ELI-5

When the app needs *all* the assets for a tag (like every input image),
it asks the server for them one page at a time. Today it says "give me
page starting at item #500" (offset paging). If items get added or
removed while it's flipping through, pages shift and it can show the
same thing twice or skip something.

This switches to "give me the page *after this bookmark*" (cursor
paging). The server now hands back a `next_cursor` bookmark with each
page; we pass it to fetch the next one. Bookmarks don't slip when the
list changes underneath, so the walk is stable and drift-free.

## What

Migrates the full-walk asset pager (`getAllAssetsByTag`) from offset to
keyset (`after` / `next_cursor`) pagination, now that the list-assets
endpoint exposes a cursor contract in the generated types.

- `handleAssetRequest` accepts an `after` cursor and sends it instead of
`offset` when present (the server ignores `offset` alongside a cursor)
- `getAllAssetsByTag` resumes each page from the prior response's
`next_cursor`, and terminates when `has_more` is false or `next_cursor`
is omitted
- `next_cursor` is exposed on the asset response schema; `after` is
threaded through `getAssetsByTag` / `getAssetsPageByTag` for
cursor-aware callers
- offset remains supported for random-access callers; only the full-walk
path changes

## Why

Offset pagination double-counts or skips records when the underlying set
changes mid-walk. Keyset cursors are stable under concurrent
inserts/deletes and scale better than deep offsets.

## Stacking

Based on `update-ingest-types` because the `after`/`next_cursor` types
land there first; this targets that branch and will retarget to the
default branch once it merges. Changes here touch only the asset
service/schema, disjoint from the generated types.

## Follow-ups

The asset store's bespoke offset loops (model loader, flat-output
infinite scroll) and the missing-media resolver still walk by offset;
those migrate in separate PRs.

## Tests

`assetService.test.ts` updated to assert the cursor walk, that the first
page carries neither `after` nor `offset`, that subsequent pages resume
from `next_cursor`, and that the walk halts when `next_cursor` is absent
even if `has_more` is true. Full asset/service + missing-media + store
suites pass locally (193 tests).

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-10 21:15:19 +00:00
Robin Huang
9617e498c9 feat: track desktop download button clicks on website (#12770)
Adds a `website:download_button_clicked` PostHog event (with `platform`
property) fired when a user clicks the desktop installer download button
on comfy.org. Previously we only had `/download` pageviews as a proxy —
autocapture is not active on the website project, so these clicks were
untracked. Includes unit tests for the new capture helper.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:44:57 +00:00
Terry Jia
25205c0f55 feat: add Load3DAdvanced node (#12723)
## Summary
add Load3DAdvanced node, without FE render, upload BE and HDRI.
BE https://github.com/Comfy-Org/ComfyUI/pull/14316

## Screenshots (if applicable)

https://github.com/user-attachments/assets/e561c919-bb52-4904-97da-fb01885762a7
2026-06-10 13:51:50 -04:00
156 changed files with 3244 additions and 1517 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,23 +1,23 @@
{
"name": "Comfy",
"short_name": "Comfy",
"id": "/",
"start_url": "/",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
"purpose": "any maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
"purpose": "any maskable"
}
],
"theme_color": "#211927",
"background_color": "#211927",
"display": "standalone"
"display": "standalone",
"id": "/",
"start_url": "/"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

View File

@@ -0,0 +1,373 @@
import { describe, expect, it } from 'vitest'
import { externalLinks, getRoutes } from '../../config/routes'
import { hasKey, t } from '../../i18n/translations'
import {
AFFILIATE_FAQ_COUNT,
AFFILIATE_FAQ_HEADING_KEY,
AFFILIATE_FAQ_PREFIX
} from './affiliateFaqs'
import { brandAssets } from './brandAssets'
import { programDetailRows } from './programDetails'
// ---------------------------------------------------------------------------
// brandAssets.ts
// ---------------------------------------------------------------------------
describe('brandAssets data integrity', () => {
it('exports a non-empty array', () => {
expect(Array.isArray(brandAssets)).toBe(true)
expect(brandAssets.length).toBeGreaterThan(0)
})
it('has exactly 8 brand assets', () => {
expect(brandAssets).toHaveLength(8)
})
it('every asset has a non-empty id', () => {
for (const asset of brandAssets) {
expect(asset.id.trim().length, `asset id is empty`).toBeGreaterThan(0)
}
})
it('every asset id is unique', () => {
const ids = brandAssets.map((a) => a.id)
const unique = new Set(ids)
expect(unique.size).toBe(ids.length)
})
it('every asset id uses kebab-case only (no spaces or uppercase letters)', () => {
const kebabCase = /^[a-z0-9]+(-[a-z0-9]+)*$/
for (const asset of brandAssets) {
expect(
kebabCase.test(asset.id),
`asset id "${asset.id}" is not kebab-case`
).toBe(true)
}
})
it('every asset download path is a non-empty string beginning with "/"', () => {
for (const asset of brandAssets) {
expect(
asset.download.length,
`download path for "${asset.id}" is empty`
).toBeGreaterThan(0)
expect(
asset.download.startsWith('/'),
`download path for "${asset.id}" does not start with "/"`
).toBe(true)
}
})
it('every asset preview path is a non-empty string beginning with "/"', () => {
for (const asset of brandAssets) {
expect(
asset.preview.length,
`preview path for "${asset.id}" is empty`
).toBeGreaterThan(0)
expect(
asset.preview.startsWith('/'),
`preview path for "${asset.id}" does not start with "/"`
).toBe(true)
}
})
it('every asset download path has a recognisable file extension', () => {
const knownExtensions = /\.(svg|png|jpg|jpeg|webp|gif|zip)$/i
for (const asset of brandAssets) {
expect(
knownExtensions.test(asset.download),
`download path "${asset.download}" has no recognised extension`
).toBe(true)
}
})
it('every asset titleKey is a valid translation key with non-empty English copy', () => {
for (const asset of brandAssets) {
expect(
hasKey(asset.titleKey),
`titleKey "${asset.titleKey}" not found in translations`
).toBe(true)
expect(
t(asset.titleKey, 'en').trim().length,
`titleKey "${asset.titleKey}" has empty English copy`
).toBeGreaterThan(0)
}
})
it('every asset titleKey starts with "affiliate-landing.assets.tile."', () => {
const TILE_PREFIX = 'affiliate-landing.assets.tile.'
for (const asset of brandAssets) {
expect(
asset.titleKey.startsWith(TILE_PREFIX),
`titleKey "${asset.titleKey}" does not start with "${TILE_PREFIX}"`
).toBe(true)
}
})
it('the comfy-amplified-logo.png asset was removed (renamed to svg variants)', () => {
// Regression guard: the PR deleted comfy-amplified-logo.png and the old PNG
// download path should no longer appear in brandAssets.
const hasPngAmplifiedLogo = brandAssets.some(
(a) => a.download.endsWith('comfy-amplified-logo.png')
)
expect(hasPngAmplifiedLogo).toBe(false)
})
})
// ---------------------------------------------------------------------------
// programDetails.ts
// ---------------------------------------------------------------------------
describe('programDetailRows data integrity', () => {
it('exports a non-empty array', () => {
expect(Array.isArray(programDetailRows)).toBe(true)
expect(programDetailRows.length).toBeGreaterThan(0)
})
it('has exactly 6 program detail rows', () => {
expect(programDetailRows).toHaveLength(6)
})
it('all labelKeys are unique', () => {
const labelKeys = programDetailRows.map((r) => r.labelKey)
const unique = new Set(labelKeys)
expect(unique.size).toBe(labelKeys.length)
})
it('all valueKeys are unique', () => {
const valueKeys = programDetailRows.map((r) => r.valueKey)
const unique = new Set(valueKeys)
expect(unique.size).toBe(valueKeys.length)
})
it('no row shares a labelKey with its valueKey', () => {
for (const row of programDetailRows) {
expect(row.labelKey).not.toBe(row.valueKey)
}
})
it('all labelKeys are valid translation keys with non-empty English copy', () => {
for (const row of programDetailRows) {
expect(
hasKey(row.labelKey),
`labelKey "${row.labelKey}" not found in translations`
).toBe(true)
expect(
t(row.labelKey, 'en').trim().length,
`labelKey "${row.labelKey}" has empty English copy`
).toBeGreaterThan(0)
}
})
it('all valueKeys are valid translation keys with non-empty English copy', () => {
for (const row of programDetailRows) {
expect(
hasKey(row.valueKey),
`valueKey "${row.valueKey}" not found in translations`
).toBe(true)
expect(
t(row.valueKey, 'en').trim().length,
`valueKey "${row.valueKey}" has empty English copy`
).toBeGreaterThan(0)
}
})
it('all labelKeys follow the "affiliate-landing.details.row.<n>.label" pattern', () => {
const pattern = /^affiliate-landing\.details\.row\.\d+\.label$/
for (const row of programDetailRows) {
expect(
pattern.test(row.labelKey),
`labelKey "${row.labelKey}" does not match expected pattern`
).toBe(true)
}
})
it('all valueKeys follow the "affiliate-landing.details.row.<n>.value" pattern', () => {
const pattern = /^affiliate-landing\.details\.row\.\d+\.value$/
for (const row of programDetailRows) {
expect(
pattern.test(row.valueKey),
`valueKey "${row.valueKey}" does not match expected pattern`
).toBe(true)
}
})
it('row indices are zero-based and contiguous', () => {
const indexRegex = /\.row\.(\d+)\.label$/
const indices = programDetailRows
.map((r) => r.labelKey.match(indexRegex)?.[1])
.filter((m): m is string => m !== undefined)
.map((s) => parseInt(s, 10))
expect(indices).toEqual(
Array.from({ length: programDetailRows.length }, (_, i) => i)
)
})
})
// ---------------------------------------------------------------------------
// affiliateFaqs.ts — constant values and types
// ---------------------------------------------------------------------------
describe('affiliateFaqs constants', () => {
it('AFFILIATE_FAQ_PREFIX is exactly "affiliate-landing.faq"', () => {
expect(AFFILIATE_FAQ_PREFIX).toBe('affiliate-landing.faq')
})
it('AFFILIATE_FAQ_HEADING_KEY is exactly "affiliate-landing.faq.heading"', () => {
expect(AFFILIATE_FAQ_HEADING_KEY).toBe('affiliate-landing.faq.heading')
})
it('AFFILIATE_FAQ_HEADING_KEY starts with AFFILIATE_FAQ_PREFIX', () => {
expect(AFFILIATE_FAQ_HEADING_KEY.startsWith(AFFILIATE_FAQ_PREFIX)).toBe(
true
)
})
it('AFFILIATE_FAQ_COUNT is a positive integer', () => {
expect(Number.isInteger(AFFILIATE_FAQ_COUNT)).toBe(true)
expect(AFFILIATE_FAQ_COUNT).toBeGreaterThan(0)
})
it('AFFILIATE_FAQ_COUNT is 8 (regression guard against accidental changes)', () => {
expect(AFFILIATE_FAQ_COUNT).toBe(8)
})
it('AFFILIATE_FAQ_HEADING_KEY resolves to a non-empty English string', () => {
expect(hasKey(AFFILIATE_FAQ_HEADING_KEY)).toBe(true)
expect(t(AFFILIATE_FAQ_HEADING_KEY, 'en').trim().length).toBeGreaterThan(0)
})
it('there are no FAQ keys beyond AFFILIATE_FAQ_COUNT', () => {
const beyondCount = hasKey(
`${AFFILIATE_FAQ_PREFIX}.${AFFILIATE_FAQ_COUNT + 1}.q` as never
)
expect(beyondCount).toBe(false)
})
})
// ---------------------------------------------------------------------------
// FooterCtaSection.vue config dependencies
// ---------------------------------------------------------------------------
describe('FooterCtaSection config dependencies', () => {
it('externalLinks.affiliateApplicationForm is the canonical Google Form URL', () => {
expect(externalLinks.affiliateApplicationForm).toBe(
'https://forms.gle/RS8L2ttcuGap4Q1v6'
)
})
it('externalLinks.affiliateApplicationForm is a well-formed https URL', () => {
expect(() => new URL(externalLinks.affiliateApplicationForm)).not.toThrow()
expect(
new URL(externalLinks.affiliateApplicationForm).protocol
).toBe('https:')
})
it('affiliateTerms route is "/affiliates/terms" for English locale', () => {
expect(getRoutes('en').affiliateTerms).toBe('/affiliates/terms')
})
it('affiliateTerms route is locale-invariant (same for zh-CN)', () => {
// Guards against re-introducing /zh-CN/affiliates/terms, which would
// bypass the legal review that applies only to the English copy.
expect(getRoutes('zh-CN').affiliateTerms).toBe('/affiliates/terms')
})
it('affiliates base route uses the expected path', () => {
expect(getRoutes('en').affiliates).toBe('/affiliates')
})
it('footer CTA copy keys are present in translations', () => {
expect(hasKey('affiliate-landing.footerCta.heading')).toBe(true)
expect(hasKey('affiliate-landing.footerCta.termsLink')).toBe(true)
expect(hasKey('affiliate-landing.cta.apply')).toBe(true)
expect(hasKey('affiliate-landing.cta.applyAriaLabel')).toBe(true)
})
it('footer CTA copy keys return non-empty English strings', () => {
const keys = [
'affiliate-landing.footerCta.heading',
'affiliate-landing.footerCta.termsLink',
'affiliate-landing.cta.apply',
'affiliate-landing.cta.applyAriaLabel'
] as const
for (const key of keys) {
expect(t(key, 'en').trim().length, `key "${key}" is empty`).toBeGreaterThan(0)
}
})
})
// ---------------------------------------------------------------------------
// AudienceSection.vue — translation key contract
// ---------------------------------------------------------------------------
describe('AudienceSection translation keys', () => {
const AUDIENCE_ITEM_COUNT = 5
const AUDIENCE_PREFIX = 'affiliate-landing.audience'
it('audience heading key exists and is non-empty', () => {
expect(hasKey(`${AUDIENCE_PREFIX}.heading`)).toBe(true)
expect(t(`${AUDIENCE_PREFIX}.heading` as never, 'en').trim().length).toBeGreaterThan(0)
})
it(`provides exactly ${AUDIENCE_ITEM_COUNT} audience item keys (item.0 through item.4)`, () => {
for (let i = 0; i < AUDIENCE_ITEM_COUNT; i++) {
expect(
hasKey(`${AUDIENCE_PREFIX}.item.${i}`),
`missing key: ${AUDIENCE_PREFIX}.item.${i}`
).toBe(true)
}
})
it('does not have an audience item beyond index 4 (prevents silent skipping)', () => {
expect(hasKey(`${AUDIENCE_PREFIX}.item.${AUDIENCE_ITEM_COUNT}` as never)).toBe(false)
})
it('all audience item keys return non-empty English text', () => {
for (let i = 0; i < AUDIENCE_ITEM_COUNT; i++) {
const key = `${AUDIENCE_PREFIX}.item.${i}` as never
expect(
t(key, 'en').trim().length,
`audience item ${i} has empty English copy`
).toBeGreaterThan(0)
}
})
})
// ---------------------------------------------------------------------------
// BrandAssetsSection.vue — section-level translation key contract
// ---------------------------------------------------------------------------
describe('BrandAssetsSection translation keys', () => {
const ASSETS_PREFIX = 'affiliate-landing.assets'
it('heading, subheading, and downloadLabel keys all exist', () => {
expect(hasKey(`${ASSETS_PREFIX}.heading`)).toBe(true)
expect(hasKey(`${ASSETS_PREFIX}.subheading`)).toBe(true)
expect(hasKey(`${ASSETS_PREFIX}.downloadLabel`)).toBe(true)
})
it('all section-level keys return non-empty English copy', () => {
const keys = [
`${ASSETS_PREFIX}.heading`,
`${ASSETS_PREFIX}.subheading`,
`${ASSETS_PREFIX}.downloadLabel`
] as const
for (const key of keys) {
expect(t(key as never, 'en').trim().length, `key "${key}" is empty`).toBeGreaterThan(0)
}
})
it('every asset titleKey starts under the tile namespace', () => {
const tilePrefix = `${ASSETS_PREFIX}.tile.`
for (const asset of brandAssets) {
expect(
asset.titleKey.startsWith(tilePrefix),
`titleKey "${asset.titleKey}" doesn't start with "${tilePrefix}"`
).toBe(true)
}
// Guard: the BrandAssetsSection renders one card per entry in brandAssets
expect(brandAssets.length).toBe(8)
})
})

View File

@@ -1,10 +1,20 @@
<script setup lang="ts">
import type { EventItem } from '../../content.config'
import type { Locale, TranslationKey } from '../../i18n/translations'
import type {
Locale,
LocalizedText,
TranslationKey
} from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from './BrandButton.vue'
export type EventItem = {
label: LocalizedText
title: LocalizedText
cta: LocalizedText
href: string
}
const {
locale = 'en',
headingKey,
@@ -30,12 +40,12 @@ const {
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
<div class="flex flex-col gap-8">
<h2
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
>
{{ t(headingKey, locale) }}
</h2>
<p
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
>
{{ t(descriptionKey, locale) }}
</p>
@@ -56,20 +66,20 @@ const {
v-for="(event, i) in events"
:key="i"
:href="event.href"
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
>
<span
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
>
{{ event.label }}
{{ event.label[locale] }}
</span>
<span class="text-primary-warm-gray flex-1 text-sm">
{{ event.title }}
{{ event.title[locale] }}
</span>
<span
class="text-primary-comfy-yellow flex shrink-0 items-center gap-2 text-sm"
>
{{ event.cta }}
{{ event.cta[locale] }}
<svg
class="size-4 transition-transform group-hover:translate-x-0.5"
viewBox="0 0 24 24"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import GalleryItemAttribution from './GalleryItemAttribution.vue'
@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
<div class="flex w-full items-end justify-between p-4">
<div class="gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
<GalleryItemAttribution :item :locale />
</p>
</div>
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
<!-- Mobile metadata -->
<div v-if="mobile" class="mt-2 gap-2">
<p class="text-sm font-bold text-white">{{ item.title }}</p>
<p class="text-xs text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas text-xs">
<GalleryItemAttribution :item :locale />
</p>
</div>

View File

@@ -12,7 +12,7 @@ import {
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { prefersReducedMotion } from '../../composables/useReducedMotion'
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
@@ -123,11 +123,11 @@ onUnmounted(() => {
<!-- Close button -->
<button
:aria-label="t('gallery.detail.close', locale)"
class="border-primary-comfy-yellow hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 bg-primary-comfy-ink transition-colors lg:top-8 lg:right-26"
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:top-8 lg:right-26"
@click="emit('close')"
>
<span
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
style="mask: url('/icons/close.svg') center / contain no-repeat"
/>
</button>
@@ -136,7 +136,7 @@ onUnmounted(() => {
<div class="relative hidden min-h-0 w-full flex-1 pt-12 lg:flex">
<!-- Left: info card -->
<div
class="bg-primary-comfy-yellow rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8 text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink rounded-5xl relative z-10 flex w-80 shrink-0 flex-col justify-between self-start p-8"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"
@@ -170,7 +170,7 @@ onUnmounted(() => {
<!-- Right: large image -->
<div
class="border-primary-comfy-yellow rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-4"
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex max-h-full min-h-0 flex-1 items-center justify-center overflow-hidden border-2 p-4"
>
<component
:is="activeItem.video ? 'video' : 'img'"
@@ -197,7 +197,7 @@ onUnmounted(() => {
>
<!-- Image -->
<div
class="border-primary-comfy-yellow flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 bg-primary-comfy-ink p-3"
class="border-primary-comfy-yellow bg-primary-comfy-ink flex w-full flex-1 items-center overflow-hidden rounded-4xl border-2 p-3"
>
<component
:is="activeItem.video ? 'video' : 'img'"
@@ -223,7 +223,7 @@ onUnmounted(() => {
<!-- Info card -->
<div
class="bg-primary-comfy-yellow w-full rounded-4xl p-6 text-primary-comfy-ink"
class="bg-primary-comfy-yellow text-primary-comfy-ink w-full rounded-4xl p-6"
>
<div
:class="transitioning ? 'opacity-0' : 'opacity-100'"

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'

View File

@@ -2,15 +2,13 @@
import { cn } from '@comfyorg/tailwind-utils'
import { ref } from 'vue'
import type { GalleryItem } from '../../content.config'
import { visibleGalleryItems as items } from '../../data/gallery'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import GalleryCard from './GalleryCard.vue'
import GalleryDetailModal from './GalleryDetailModal.vue'
const { items, locale = 'en' } = defineProps<{
items: GalleryItem[]
locale?: Locale
}>()
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const modalOpen = ref(false)
const modalIndex = ref(0)

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
import type { ResolvedTutorial } from '../../content.config'
import type { LearningTutorial } from '../../data/learningTutorials'
import type { Locale } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
@@ -9,7 +9,7 @@ import { t } from '../../i18n/translations'
import VideoPlayer from '../common/VideoPlayer.vue'
const { tutorial, locale = 'en' } = defineProps<{
tutorial: ResolvedTutorial
tutorial: LearningTutorial
locale?: Locale
}>()
@@ -39,7 +39,7 @@ onUnmounted(() => {
<Teleport to="body">
<dialog
ref="dialogRef"
:aria-label="tutorial.title"
:aria-label="tutorial.title[locale]"
class="fixed inset-0 z-50 flex size-full max-h-none max-w-none flex-col items-center justify-center border-0 bg-transparent px-4 py-8 backdrop-blur-xl backdrop:bg-transparent lg:px-20 lg:py-8"
@click="handleBackdropClick"
@keydown="handleKeydown"
@@ -60,7 +60,7 @@ onUnmounted(() => {
class="border-primary-comfy-yellow rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-3 lg:p-4"
>
<VideoPlayer
:key="tutorial.slug"
:key="tutorial.id"
:locale
:src="tutorial.videoSrc"
:poster="tutorial.poster"
@@ -73,7 +73,7 @@ onUnmounted(() => {
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title }}
{{ tutorial.title[locale] }}
</h2>
</dialog>
</Teleport>

View File

@@ -1,23 +1,22 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { ResolvedTutorial } from '../../content.config'
import type { Locale } from '../../i18n/translations'
import {
getTutorialPosterSrc,
learningTutorials
} from '../../data/learningTutorials'
import { t } from '../../i18n/translations'
import { getTutorialPosterSrc } from '../../utils/tutorial'
import Badge from '../common/Badge.vue'
import MaskRevealButton from '../common/MaskRevealButton.vue'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
const { tutorials, locale = 'en' } = defineProps<{
tutorials: readonly ResolvedTutorial[]
locale?: Locale
}>()
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const activeTutorialSlug = ref<string | null>(null)
const activeTutorialId = ref<string | null>(null)
const activeTutorial = () =>
tutorials.find((tutorial) => tutorial.slug === activeTutorialSlug.value)
learningTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
</script>
<template>
@@ -32,15 +31,15 @@ const activeTutorial = () =>
class="grid grid-cols-1 gap-x-6 gap-y-10 md:grid-cols-2 lg:grid-cols-3 lg:gap-x-8"
>
<li
v-for="tutorial in tutorials"
:key="tutorial.slug"
v-for="tutorial in learningTutorials"
:key="tutorial.id"
class="bg-transparency-white-t4 flex flex-col gap-4 overflow-hidden rounded-3xl border-0 p-2"
>
<button
type="button"
class="group relative block aspect-video cursor-pointer overflow-hidden rounded-3xl"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title}`"
@click="activeTutorialSlug = tutorial.slug"
:aria-label="`${t('learning.tutorials.titlePrefix', locale)} ${tutorial.title[locale]}`"
@click="activeTutorialId = tutorial.id"
>
<video
:src="getTutorialPosterSrc(tutorial)"
@@ -75,7 +74,7 @@ const activeTutorial = () =>
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title }}
{{ tutorial.title[locale] }}
</h3>
<MaskRevealButton
v-if="tutorial.href"
@@ -104,7 +103,7 @@ const activeTutorial = () =>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ tag }}</Badge>
<Badge>{{ t(tag, locale) }}</Badge>
</li>
</ul>
</div>
@@ -115,7 +114,7 @@ const activeTutorial = () =>
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
@close="activeTutorialSlug = null"
@close="activeTutorialId = null"
/>
</section>
</template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { GalleryItem } from '../../content.config'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
@@ -9,14 +9,64 @@ import BrandButton from '../common/BrandButton.vue'
import GalleryCard from '../gallery/GalleryCard.vue'
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
const { items, locale = 'en' } = defineProps<{
items: readonly GalleryItem[]
locale?: Locale
}>()
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const modelName = 'Grok'
const ctaHref = 'https://comfy.org/workflows/model/grok'
const items: GalleryItem[] = [
{
id: 'subway-swan',
image: 'https://media.comfy.org/website/gallery/subway-swan_compressed.png',
title: 'Subway Swan',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'milos-little-wonder',
video:
'https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4',
title: 'Milos Little Wonder',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'amber-passage',
image:
'https://media.comfy.org/website/gallery/amber-passage_compressed.jpg',
title: 'Amber Passage',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats',
objectPosition: 'bottom'
},
{
id: 'neon-revenant',
video:
'https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4',
title: 'Neon Revenant',
userAlias: 'Eric Solorio',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.instagram.com/enigmatic_e'
},
{
id: 'midnight-umami',
image:
'https://media.comfy.org/website/gallery/midnight_umami_compressed.png',
title: 'Midnight Umami',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
}
]
const modalOpen = ref(false)
const modalIndex = ref(0)

View File

@@ -8,6 +8,7 @@ import {
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import { captureDownloadClick } from '../../../scripts/posthog'
import BrandButton from '../../common/BrandButton.vue'
const { locale = 'en', class: customClass = '' } = defineProps<{
@@ -69,6 +70,7 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">
<img

View File

@@ -1,131 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('astro:content', () => ({
defineCollection: (config: unknown) => config
}))
vi.mock('astro/loaders', () => ({
glob: () => ({})
}))
import { eventsSchema, gallerySchema, tutorialsSchema } from './content.config'
const validEntry = {
order: 1,
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
href: 'https://www.thinkdiffusion.com/studio'
}
describe('gallerySchema', () => {
it('accepts a valid entry', () => {
const result = gallerySchema.safeParse(validEntry)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validEntry
const result = gallerySchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('defaults visible to true when omitted', () => {
const result = gallerySchema.safeParse(validEntry)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.visible).toBe(true)
}
})
it('preserves an explicit visible: false', () => {
const result = gallerySchema.safeParse({ ...validEntry, visible: false })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.visible).toBe(false)
}
})
it('rejects an invalid URL in image/video/href', () => {
const result = gallerySchema.safeParse({
...validEntry,
href: 'not a url'
})
expect(result.success).toBe(false)
})
})
const validEvent = {
order: 1,
label: 'Live Stream:',
title: 'Zero to Node: Building Your First Workflow',
cta: 'Link',
href: 'https://example.com/event'
}
describe('eventsSchema', () => {
it('accepts a valid entry', () => {
const result = eventsSchema.safeParse(validEvent)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validEvent
const result = eventsSchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('accepts "#" as a placeholder href', () => {
const result = eventsSchema.safeParse({ ...validEvent, href: '#' })
expect(result.success).toBe(true)
})
it('rejects a truly malformed href that is neither a URL nor "#"', () => {
const result = eventsSchema.safeParse({ ...validEvent, href: 'not a url' })
expect(result.success).toBe(false)
})
})
const validTutorial = {
order: 1,
tags: ['Partner Nodes', 'Image To Video'],
title: 'Cleanplate Walkthrough',
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg'
}
describe('tutorialsSchema', () => {
it('accepts a valid entry', () => {
const result = tutorialsSchema.safeParse(validTutorial)
expect(result.success).toBe(true)
})
it('rejects an entry missing a required field with a Zod error naming the field', () => {
const { title: _omit, ...withoutTitle } = validTutorial
const result = tutorialsSchema.safeParse(withoutTitle)
expect(result.success).toBe(false)
if (!result.success) {
const paths = result.error.issues.map((issue) => issue.path.join('.'))
expect(paths).toContain('title')
}
})
it('rejects an invalid videoSrc URL', () => {
const result = tutorialsSchema.safeParse({
...validTutorial,
videoSrc: 'not a url'
})
expect(result.success).toBe(false)
})
})

View File

@@ -1,78 +0,0 @@
import { defineCollection } from 'astro:content'
import { glob } from 'astro/loaders'
import { z } from 'astro/zod'
export const gallerySchema = z.object({
order: z.number().int(),
image: z.string().url().optional(),
video: z.string().url().optional(),
title: z.string(),
userAlias: z.string(),
teamAlias: z.string(),
tool: z.string(),
href: z.string().url().optional(),
objectPosition: z.string().optional(),
objectFit: z.string().optional(),
visible: z.boolean().default(true)
})
export type GalleryItem = z.infer<typeof gallerySchema>
export const eventsSchema = z.object({
order: z.number().int(),
label: z.string(),
title: z.string(),
cta: z.string(),
href: z.string().url().or(z.literal('#'))
})
export type EventItem = z.infer<typeof eventsSchema>
export const tutorialsSchema = z.object({
order: z.number().int(),
tags: z.array(z.string()),
title: z.string(),
videoSrc: z.string().url(),
href: z.string().url().optional(),
poster: z.string().url().optional(),
posterTime: z.number().optional()
})
export type LearningTutorial = z.infer<typeof tutorialsSchema>
export type ResolvedTutorial = LearningTutorial & { slug: string }
// The default `generateId` lowercases path segments (e.g. `zh-CN/foo` becomes
// `zh-cn/foo`), which collides with the BCP-47 locale codes Astro's i18n
// config uses elsewhere. Strip the file extension to keep the path — and
// therefore the locale prefix — verbatim.
const preservePathId = ({ entry }: { entry: string }): string =>
entry.replace(/\.[^.]+$/, '')
const gallery = defineCollection({
loader: glob({
pattern: '**/*.json',
base: './src/content/gallery',
generateId: preservePathId
}),
schema: gallerySchema
})
const events = defineCollection({
loader: glob({
pattern: '**/*.json',
base: './src/content/events',
generateId: preservePathId
}),
schema: eventsSchema
})
const tutorials = defineCollection({
loader: glob({
pattern: '**/*.md',
base: './src/content/tutorials',
generateId: preservePathId
}),
schema: tutorialsSchema
})
export const collections = { gallery, events, tutorials }

View File

@@ -1,7 +0,0 @@
{
"order": 2,
"label": "Event 1",
"title": "Lorem ipsum dollar sita met",
"cta": "London, UK",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 3,
"label": "Event 2",
"title": "Lorem ipsum dollar sita met",
"cta": "San Francisco",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 1,
"label": "Live Stream:",
"title": "Zero to Node: Building Your First Workflow",
"cta": "Link",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 2,
"label": "活动 1",
"title": "此处为活动描述的占位文本",
"cta": "英国伦敦",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 3,
"label": "活动 2",
"title": "此处为活动描述的占位文本",
"cta": "旧金山",
"href": "#"
}

View File

@@ -1,7 +0,0 @@
{
"order": 1,
"label": "直播:",
"title": "从零到节点:构建你的第一个工作流",
"cta": "链接",
"href": "#"
}

View File

@@ -1,9 +0,0 @@
{
"order": 17,
"image": "https://media.comfy.org/website/gallery/gallery.webp",
"title": "Amber Astronaut",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,11 +0,0 @@
{
"order": 21,
"image": "https://media.comfy.org/website/gallery/amber-passage_compressed.jpg",
"title": "Amber Passage",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"objectPosition": "bottom",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 16,
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
"title": "Animation Reel",
"userAlias": "Andidea",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
}

View File

@@ -1,9 +0,0 @@
{
"order": 5,
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
"title": "Autopoiesis",
"userAlias": "Yogo",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/visualfrisson/?hl=en"
}

View File

@@ -1,9 +0,0 @@
{
"order": 13,
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
"title": "Cuco - A Love Letter To LA",
"userAlias": "Paul Trillo",
"teamAlias": "CoffeeVectors",
"tool": "ComfyUI",
"href": "https://vimeo.com/1062859798"
}

View File

@@ -1,9 +0,0 @@
{
"order": 12,
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
"title": "DDU-DU DDU-DU",
"userAlias": "Purz",
"teamAlias": "Andidea",
"tool": "Animatediff",
"href": "https://vimeo.com/1019924290"
}

View File

@@ -1,9 +0,0 @@
{
"order": 18,
"image": "https://media.comfy.org/website/gallery/desert.webp",
"title": "Desert Landing",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,9 +0,0 @@
{
"order": 6,
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
"title": "Eat It - Dance",
"userAlias": "Johana Lyu",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.joannalyu.com/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 7,
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
"title": "Fall",
"userAlias": "Nathan Shipley",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 11,
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
"title": "It's gonna be a good good summer",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685900"
}

View File

@@ -1,9 +0,0 @@
{
"order": 15,
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
"title": "Goodbye Beijing",
"userAlias": "Rui",
"teamAlias": "makeitrad",
"tool": "Animatediff",
"href": "https://x.com/rui40000"
}

View File

@@ -1,10 +0,0 @@
{
"order": 23,
"image": "https://media.comfy.org/website/gallery/midnight_umami_compressed.png",
"title": "Midnight Umami",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"visible": false
}

View File

@@ -1,10 +0,0 @@
{
"order": 20,
"video": "https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4",
"title": "Milos Little Wonder",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 3,
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
"title": "Neon Nights",
"userAlias": "ShaneF Motion Design",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
}

View File

@@ -1,10 +0,0 @@
{
"order": 22,
"video": "https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4",
"title": "Neon Revenant",
"userAlias": "Eric Solorio",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.instagram.com/enigmatic_e",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 9,
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
"title": "Origami world",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 2,
"video": "https://media.comfy.org/videos/compressed_512/kyrie.webm",
"title": "Origins - Kyrie Irving",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://vimeo.com/1021360563"
}

View File

@@ -1,9 +0,0 @@
{
"order": 10,
"video": "https://media.comfy.org/videos/compressed_512/biking.webm",
"title": "Shot on InstaX",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 14,
"video": "https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm",
"title": "Show you my garden",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685479"
}

View File

@@ -1,10 +0,0 @@
{
"order": 19,
"image": "https://media.comfy.org/website/gallery/subway-swan_compressed.png",
"title": "Subway Swan",
"userAlias": "Purz Beats",
"teamAlias": "Comfy",
"tool": "Grok Imagine",
"href": "https://www.youtube.com/@PurzBeats",
"visible": false
}

View File

@@ -1,9 +0,0 @@
{
"order": 1,
"video": "https://media.comfy.org/videos/compressed_512/eye.webm",
"title": "Until Our Eye Interlink harajuku",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://www.thinkdiffusion.com/studio#success-stories-anta"
}

View File

@@ -1,9 +0,0 @@
{
"order": 8,
"video": "https://media.comfy.org/videos/compressed_512/buildings.webm",
"title": "Untitled",
"userAlias": "Nathan Shipley",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C6rEuJ4p9xU/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 4,
"video": "https://media.comfy.org/videos/compressed_512/dusk_mountains.webm",
"title": "Untitled",
"userAlias": "MidJourney man",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/midjourney.man/?hl=fr"
}

View File

@@ -1,9 +0,0 @@
{
"order": 17,
"image": "https://media.comfy.org/website/gallery/gallery.webp",
"title": "Amber Astronaut",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,9 +0,0 @@
{
"order": 16,
"video": "https://media.comfy.org/videos/compressed_512/clouds_statue.webm",
"title": "Animation Reel",
"userAlias": "Andidea",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.youtube.com/watch?v=qu3eIQ1uln8"
}

View File

@@ -1,9 +0,0 @@
{
"order": 5,
"video": "https://media.comfy.org/videos/compressed_512/cigarette.webm",
"title": "Autopoiesis",
"userAlias": "Yogo",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/visualfrisson/?hl=en"
}

View File

@@ -1,9 +0,0 @@
{
"order": 13,
"video": "https://media.comfy.org/videos/compressed_512/paul_trillo.webm",
"title": "Cuco - A Love Letter To LA",
"userAlias": "Paul Trillo",
"teamAlias": "CoffeeVectors",
"tool": "ComfyUI",
"href": "https://vimeo.com/1062859798"
}

View File

@@ -1,9 +0,0 @@
{
"order": 12,
"video": "https://media.comfy.org/videos/compressed_512/dududu.webm",
"title": "DDU-DU DDU-DU",
"userAlias": "Purz",
"teamAlias": "Andidea",
"tool": "Animatediff",
"href": "https://vimeo.com/1019924290"
}

View File

@@ -1,9 +0,0 @@
{
"order": 18,
"image": "https://media.comfy.org/website/gallery/desert.webp",
"title": "Desert Landing",
"userAlias": "Yogo",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://de.linkedin.com/in/milan-kastenmueller-18778a174"
}

View File

@@ -1,9 +0,0 @@
{
"order": 6,
"video": "https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm",
"title": "Eat It - Dance",
"userAlias": "Johana Lyu",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.joannalyu.com/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 7,
"video": "https://media.comfy.org/videos/compressed_512/flower.webm",
"title": "Fall",
"userAlias": "Nathan Shipley",
"teamAlias": "Visual Frisson",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C3k9t_6vH5F/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 11,
"video": "https://media.comfy.org/videos/compressed_512/clouds.webm",
"title": "It's gonna be a good good summer",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685900"
}

View File

@@ -1,9 +0,0 @@
{
"order": 15,
"video": "https://media.comfy.org/videos/compressed_512/swings.webm",
"title": "Goodbye Beijing",
"userAlias": "Rui",
"teamAlias": "makeitrad",
"tool": "Animatediff",
"href": "https://x.com/rui40000"
}

View File

@@ -1,9 +0,0 @@
{
"order": 3,
"video": "https://media.comfy.org/videos/compressed_512/arcade.webm",
"title": "Neon Nights",
"userAlias": "ShaneF Motion Design",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C1kG1oErzUV/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 9,
"video": "https://media.comfy.org/videos/compressed_512/origami_shortened.webm",
"title": "Origami world",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 2,
"video": "https://media.comfy.org/videos/compressed_512/kyrie.webm",
"title": "Origins - Kyrie Irving",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://vimeo.com/1021360563"
}

View File

@@ -1,9 +0,0 @@
{
"order": 10,
"video": "https://media.comfy.org/videos/compressed_512/biking.webm",
"title": "Shot on InstaX",
"userAlias": "Karen X",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/karenxcheng/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 14,
"video": "https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm",
"title": "Show you my garden",
"userAlias": "Paul Trillo",
"teamAlias": "",
"tool": "CogvideoX",
"href": "https://vimeo.com/1019685479"
}

View File

@@ -1,9 +0,0 @@
{
"order": 1,
"video": "https://media.comfy.org/videos/compressed_512/eye.webm",
"title": "Until Our Eye Interlink harajuku",
"userAlias": "ShaneF Motion Design",
"teamAlias": "ThinkDiffusion",
"tool": "ComfyUI",
"href": "https://www.thinkdiffusion.com/studio#success-stories-anta"
}

View File

@@ -1,9 +0,0 @@
{
"order": 8,
"video": "https://media.comfy.org/videos/compressed_512/buildings.webm",
"title": "Untitled",
"userAlias": "Nathan Shipley",
"teamAlias": "",
"tool": "ComfyUI",
"href": "https://www.instagram.com/p/C6rEuJ4p9xU/"
}

View File

@@ -1,9 +0,0 @@
{
"order": 4,
"video": "https://media.comfy.org/videos/compressed_512/dusk_mountains.webm",
"title": "Untitled",
"userAlias": "MidJourney man",
"teamAlias": "DOGSTUDIO/DEPT\u00ae",
"tool": "ComfyUI",
"href": "https://www.instagram.com/midjourney.man/?hl=fr"
}

View File

@@ -1,264 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCollection } from 'astro:content'
import {
getEventsByLocale,
getGalleryByIds,
getTutorialsByLocale,
getVisibleGalleryByLocale,
slugOf
} from './queries'
vi.mock('astro:content', () => ({
getCollection: vi.fn()
}))
const getCollectionMock = vi.mocked(getCollection)
interface FixtureEntry {
id: string
collection: 'gallery'
data: {
order: number
title: string
userAlias: string
teamAlias: string
tool: string
image?: string
video?: string
href?: string
objectPosition?: string
objectFit?: string
visible: boolean
}
}
function entry(
id: string,
overrides: Partial<FixtureEntry['data']> = {}
): FixtureEntry {
return {
id,
collection: 'gallery',
data: {
order: 0,
title: 'Title',
userAlias: 'User',
teamAlias: 'Team',
tool: 'ComfyUI',
visible: true,
...overrides
}
}
}
beforeEach(() => {
getCollectionMock.mockReset()
})
describe('slugOf', () => {
it('strips the locale prefix from an entry id', () => {
const entry = { id: 'en/until-our-eye-interlink-harajuku' }
expect(slugOf(entry)).toBe('until-our-eye-interlink-harajuku')
})
it('handles zh-CN prefix', () => {
const entry = { id: 'zh-CN/desert-landing' }
expect(slugOf(entry)).toBe('desert-landing')
})
})
describe('getVisibleGalleryByLocale', () => {
it('returns only entries whose id starts with the requested locale prefix', async () => {
getCollectionMock.mockResolvedValue([
entry('en/alpha'),
entry('en/beta'),
entry('zh-CN/alpha'),
entry('zh-CN/gamma')
] as never)
const en = await getVisibleGalleryByLocale('en')
expect(en.map((e) => e.id)).toEqual(['en/alpha', 'en/beta'])
const zh = await getVisibleGalleryByLocale('zh-CN')
expect(zh.map((e) => e.id)).toEqual(['zh-CN/alpha', 'zh-CN/gamma'])
})
it('excludes entries with visible: false', async () => {
getCollectionMock.mockResolvedValue([
entry('en/shown', { visible: true }),
entry('en/hidden', { visible: false }),
entry('en/also-shown', { visible: true })
] as never)
const result = await getVisibleGalleryByLocale('en')
expect(result.map((e) => e.id)).toEqual(['en/shown', 'en/also-shown'])
})
it('sorts entries by the order field ascending, not by id', async () => {
getCollectionMock.mockResolvedValue([
entry('en/charlie', { order: 1 }),
entry('en/alpha', { order: 3 }),
entry('en/bravo', { order: 2 })
] as never)
const result = await getVisibleGalleryByLocale('en')
expect(result.map((e) => e.id)).toEqual([
'en/charlie',
'en/bravo',
'en/alpha'
])
})
})
describe('getGalleryByIds', () => {
it('returns entries in the order of the input slug array', async () => {
getCollectionMock.mockResolvedValue([
entry('en/alpha'),
entry('en/beta'),
entry('en/gamma'),
entry('zh-CN/alpha')
] as never)
const result = await getGalleryByIds(['gamma', 'alpha', 'beta'], 'en')
expect(result.map((e) => e.id)).toEqual(['en/gamma', 'en/alpha', 'en/beta'])
})
it('drops slugs with no matching entry in the requested locale', async () => {
getCollectionMock.mockResolvedValue([
entry('en/alpha'),
entry('zh-CN/beta')
] as never)
const result = await getGalleryByIds(['alpha', 'missing', 'beta'], 'en')
expect(result.map((e) => e.id)).toEqual(['en/alpha'])
})
})
interface EventsFixtureEntry {
id: string
collection: 'events'
data: {
order: number
label: string
title: string
cta: string
href: string
}
}
function eventEntry(
id: string,
overrides: Partial<EventsFixtureEntry['data']> = {}
): EventsFixtureEntry {
return {
id,
collection: 'events',
data: {
order: 0,
label: 'Label',
title: 'Title',
cta: 'CTA',
href: '#',
...overrides
}
}
}
describe('getEventsByLocale', () => {
it('returns only entries whose id starts with the requested locale prefix', async () => {
getCollectionMock.mockResolvedValue([
eventEntry('en/alpha'),
eventEntry('en/beta'),
eventEntry('zh-CN/alpha')
] as never)
const en = await getEventsByLocale('en')
expect(en.map((e) => e.id)).toEqual(['en/alpha', 'en/beta'])
})
it('sorts entries by the order field ascending, not by id', async () => {
getCollectionMock.mockResolvedValue([
eventEntry('en/charlie', { order: 1 }),
eventEntry('en/alpha', { order: 3 }),
eventEntry('en/bravo', { order: 2 })
] as never)
const result = await getEventsByLocale('en')
expect(result.map((e) => e.id)).toEqual([
'en/charlie',
'en/bravo',
'en/alpha'
])
})
it('returns an empty array for a locale with no entries', async () => {
getCollectionMock.mockResolvedValue([
eventEntry('en/alpha'),
eventEntry('en/beta')
] as never)
const result = await getEventsByLocale('zh-CN')
expect(result).toEqual([])
})
})
interface TutorialsFixtureEntry {
id: string
collection: 'tutorials'
data: {
order: number
tags: string[]
title: string
videoSrc: string
href?: string
poster?: string
posterTime?: number
}
}
function tutorialEntry(
id: string,
overrides: Partial<TutorialsFixtureEntry['data']> = {}
): TutorialsFixtureEntry {
return {
id,
collection: 'tutorials',
data: {
order: 0,
tags: ['Tag'],
title: 'Title',
videoSrc: 'https://example.com/video.mp4',
...overrides
}
}
}
describe('getTutorialsByLocale', () => {
it('returns only entries whose id starts with the requested locale prefix', async () => {
getCollectionMock.mockResolvedValue([
tutorialEntry('en/alpha'),
tutorialEntry('en/beta'),
tutorialEntry('zh-CN/alpha')
] as never)
const en = await getTutorialsByLocale('en')
expect(en.map((e) => e.id)).toEqual(['en/alpha', 'en/beta'])
})
it('sorts entries by the order field ascending, not by id', async () => {
getCollectionMock.mockResolvedValue([
tutorialEntry('en/charlie', { order: 1 }),
tutorialEntry('en/alpha', { order: 3 }),
tutorialEntry('en/bravo', { order: 2 })
] as never)
const result = await getTutorialsByLocale('en')
expect(result.map((e) => e.id)).toEqual([
'en/charlie',
'en/bravo',
'en/alpha'
])
})
})

View File

@@ -1,61 +0,0 @@
import { getCollection } from 'astro:content'
import type { CollectionEntry } from 'astro:content'
import type { Locale } from '../i18n/translations'
export type GalleryEntry = CollectionEntry<'gallery'>
export type EventsEntry = CollectionEntry<'events'>
export type TutorialsEntry = CollectionEntry<'tutorials'>
export function slugOf(entry: { id: string }): string {
const slash = entry.id.indexOf('/')
return slash === -1 ? entry.id : entry.id.slice(slash + 1)
}
export async function getVisibleGalleryByLocale(
locale: Locale
): Promise<GalleryEntry[]> {
const prefix = `${locale}/`
const entries: GalleryEntry[] = await getCollection('gallery')
return entries
.filter(
(entry) => entry.id.startsWith(prefix) && entry.data.visible !== false
)
.sort((a, b) => a.data.order - b.data.order)
}
export async function getGalleryByIds(
slugs: readonly string[],
locale: Locale
): Promise<GalleryEntry[]> {
const entries: GalleryEntry[] = await getCollection('gallery')
const bySlug = new Map<string, GalleryEntry>()
for (const entry of entries) {
if (entry.id.startsWith(`${locale}/`)) {
bySlug.set(slugOf(entry), entry)
}
}
return slugs
.map((slug) => bySlug.get(slug))
.filter((entry): entry is GalleryEntry => entry !== undefined)
}
export async function getEventsByLocale(
locale: Locale
): Promise<EventsEntry[]> {
const prefix = `${locale}/`
const entries: EventsEntry[] = await getCollection('events')
return entries
.filter((entry) => entry.id.startsWith(prefix))
.sort((a, b) => a.data.order - b.data.order)
}
export async function getTutorialsByLocale(
locale: Locale
): Promise<TutorialsEntry[]> {
const prefix = `${locale}/`
const entries: TutorialsEntry[] = await getCollection('tutorials')
return entries
.filter((entry) => entry.id.startsWith(prefix))
.sort((a, b) => a.data.order - b.data.order)
}

View File

@@ -1,9 +0,0 @@
---
order: 1
tags:
- Partner Nodes
- Image To Video
title: Cleanplate Walkthrough
videoSrc: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4
poster: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg
---

View File

@@ -1,10 +0,0 @@
---
order: 2
tags:
- Partner Nodes
- Image To Video
title: Deaging Workflow
videoSrc: https://media.comfy.org/website/learning/deaging_workflow_v03.mp4
poster: https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=93f286fbc2c8
---

View File

@@ -1,10 +0,0 @@
---
order: 3
tags:
- Partner Nodes
- Image To Video
title: Frame Adjustments Demo
videoSrc: https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4
poster: https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=7dca0438edf4
---

View File

@@ -1,10 +0,0 @@
---
order: 4
tags:
- Partner Nodes
- Image To Video
title: Mattes and Utilities
videoSrc: https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4
poster: https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=be0889296f65
---

View File

@@ -1,10 +0,0 @@
---
order: 5
tags:
- Partner Nodes
- Image To Video
title: Seedance Demo ComfyUI
videoSrc: https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4
poster: https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=ef543bd4a773
---

View File

@@ -1,10 +0,0 @@
---
order: 6
tags:
- Partner Nodes
- Image To Video
title: Sky Replacement
videoSrc: https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4
poster: https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg
href: https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/
---

View File

@@ -1,9 +0,0 @@
---
order: 1
tags:
- 合作伙伴节点
- 图像生成视频
title: 净板演练
videoSrc: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4
poster: https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg
---

View File

@@ -1,10 +0,0 @@
---
order: 2
tags:
- 合作伙伴节点
- 图像生成视频
title: 减龄工作流
videoSrc: https://media.comfy.org/website/learning/deaging_workflow_v03.mp4
poster: https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=93f286fbc2c8
---

View File

@@ -1,10 +0,0 @@
---
order: 3
tags:
- 合作伙伴节点
- 图像生成视频
title: 帧调整演示
videoSrc: https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4
poster: https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=7dca0438edf4
---

View File

@@ -1,10 +0,0 @@
---
order: 4
tags:
- 合作伙伴节点
- 图像生成视频
title: 遮罩与实用工具
videoSrc: https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4
poster: https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=be0889296f65
---

View File

@@ -1,10 +0,0 @@
---
order: 5
tags:
- 合作伙伴节点
- 图像生成视频
title: Seedance ComfyUI 演示
videoSrc: https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4
poster: https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg
href: https://cloud.comfy.org/?share=ef543bd4a773
---

View File

@@ -1,10 +0,0 @@
---
order: 6
tags:
- 合作伙伴节点
- 图像生成视频
title: 天空替换
videoSrc: https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4
poster: https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg
href: https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/
---

View File

@@ -0,0 +1,31 @@
import type { EventItem } from '../components/common/EventsSection.vue'
export const learningEvents: readonly EventItem[] = [
{
label: { en: 'Live Stream:', 'zh-CN': '直播:' },
title: {
en: 'Zero to Node: Building Your First Workflow',
'zh-CN': '从零到节点:构建你的第一个工作流'
},
cta: { en: 'Link', 'zh-CN': '链接' },
href: '#'
},
{
label: { en: 'Event 1', 'zh-CN': '活动 1' },
title: {
en: 'Lorem ipsum dollar sita met',
'zh-CN': '此处为活动描述的占位文本'
},
cta: { en: 'London, UK', 'zh-CN': '英国伦敦' },
href: '#'
},
{
label: { en: 'Event 2', 'zh-CN': '活动 2' },
title: {
en: 'Lorem ipsum dollar sita met',
'zh-CN': '此处为活动描述的占位文本'
},
cta: { en: 'San Francisco', 'zh-CN': '旧金山' },
href: '#'
}
] as const

View File

@@ -0,0 +1,191 @@
export interface GalleryItem {
id: string
image?: string
video?: string
title: string
userAlias: string
teamAlias: string
tool: string
href?: string
objectPosition?: string
objectFit?: string
/** Defaults to true. Set to false to hide this item from rendered lists. */
visible?: boolean
}
const galleryItems: GalleryItem[] = [
{
id: 'until-our-eye-interlink-harajuku',
video: 'https://media.comfy.org/videos/compressed_512/eye.webm',
title: 'Until Our Eye Interlink harajuku',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://www.thinkdiffusion.com/studio#success-stories-anta'
},
{
id: 'origins-kyrie-irving',
video: 'https://media.comfy.org/videos/compressed_512/kyrie.webm',
title: 'Origins - Kyrie Irving',
userAlias: 'ShaneF Motion Design',
teamAlias: 'ThinkDiffusion',
tool: 'ComfyUI',
href: 'https://vimeo.com/1021360563'
},
{
id: 'neon-nights',
video: 'https://media.comfy.org/videos/compressed_512/arcade.webm',
title: 'Neon Nights',
userAlias: 'ShaneF Motion Design',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C1kG1oErzUV/'
},
{
id: 'untitled-dusk-mountains',
video: 'https://media.comfy.org/videos/compressed_512/dusk_mountains.webm',
title: 'Untitled',
userAlias: 'MidJourney man',
teamAlias: 'DOGSTUDIO/DEPT®',
tool: 'ComfyUI',
href: 'https://www.instagram.com/midjourney.man/?hl=fr'
},
{
id: 'autopoiesis',
video: 'https://media.comfy.org/videos/compressed_512/cigarette.webm',
title: 'Autopoiesis',
userAlias: 'Yogo',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/visualfrisson/?hl=en'
},
{
id: 'eat-it-dance',
video:
'https://media.comfy.org/videos/compressed_512/Eat%20It%20-%20Dance%20%5BWanAnimate%5D2.webm',
title: 'Eat It - Dance',
userAlias: 'Johana Lyu',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.joannalyu.com/'
},
{
id: 'fall',
video: 'https://media.comfy.org/videos/compressed_512/flower.webm',
title: 'Fall',
userAlias: 'Nathan Shipley',
teamAlias: 'Visual Frisson',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C3k9t_6vH5F/'
},
{
id: 'untitled-buildings',
video: 'https://media.comfy.org/videos/compressed_512/buildings.webm',
title: 'Untitled',
userAlias: 'Nathan Shipley',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/p/C6rEuJ4p9xU/'
},
{
id: 'origami-world',
video:
'https://media.comfy.org/videos/compressed_512/origami_shortened.webm',
title: 'Origami world',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
id: 'shot-on-instax',
video: 'https://media.comfy.org/videos/compressed_512/biking.webm',
title: 'Shot on InstaX',
userAlias: 'Karen X',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.instagram.com/karenxcheng/'
},
{
id: 'good-good-summer',
video: 'https://media.comfy.org/videos/compressed_512/clouds.webm',
title: "It's gonna be a good good summer",
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685900'
},
{
id: 'ddu-du-ddu-du',
video: 'https://media.comfy.org/videos/compressed_512/dududu.webm',
title: 'DDU-DU DDU-DU',
userAlias: 'Purz',
teamAlias: 'Andidea',
tool: 'Animatediff',
href: 'https://vimeo.com/1019924290'
},
{
id: 'cuco-love-letter-to-la',
video: 'https://media.comfy.org/videos/compressed_512/paul_trillo.webm',
title: 'Cuco - A Love Letter To LA',
userAlias: 'Paul Trillo',
teamAlias: 'CoffeeVectors',
tool: 'ComfyUI',
href: 'https://vimeo.com/1062859798'
},
{
id: 'show-you-my-garden',
video:
'https://media.comfy.org/videos/compressed_512/chibi_fish_tank_shortened.webm',
title: 'Show you my garden',
userAlias: 'Paul Trillo',
teamAlias: '',
tool: 'CogvideoX',
href: 'https://vimeo.com/1019685479'
},
{
id: 'goodbye-beijing',
video: 'https://media.comfy.org/videos/compressed_512/swings.webm',
title: 'Goodbye Beijing',
userAlias: 'Rui',
teamAlias: 'makeitrad',
tool: 'Animatediff',
href: 'https://x.com/rui40000'
},
{
id: 'animation-reel',
video: 'https://media.comfy.org/videos/compressed_512/clouds_statue.webm',
title: 'Animation Reel',
userAlias: 'Andidea',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://www.youtube.com/watch?v=qu3eIQ1uln8'
},
{
id: 'amber-astronaut',
image: 'https://media.comfy.org/website/gallery/gallery.webp',
title: 'Amber Astronaut',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
},
{
id: 'desert-landing',
image: 'https://media.comfy.org/website/gallery/desert.webp',
title: 'Desert Landing',
userAlias: 'Yogo',
teamAlias: '',
tool: 'ComfyUI',
href: 'https://de.linkedin.com/in/milan-kastenmueller-18778a174'
}
]
export const visibleGalleryItems: GalleryItem[] = galleryItems.filter(
(item) => item.visible !== false
)
/** @knipIgnoreUsedByStackedPR */
export function getGalleryItemById(id: string): GalleryItem | undefined {
return galleryItems.find((item) => item.id === id)
}

View File

@@ -0,0 +1,84 @@
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface LearningTutorial {
id: string
tags: readonly TranslationKey[]
title: LocalizedText
videoSrc: string
href?: string
poster?: string
posterTime?: number
}
const DEFAULT_POSTER_TIME_SECONDS = 1
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
tutorial.poster
? tutorial.poster
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
export const learningTutorials: readonly LearningTutorial[] = [
{
id: 'cleanplate_walkthrough_v03',
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
videoSrc:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/learning/cleanplate_walkthrough_v03_thumbnail.jpg',
// href: '#',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'deaging_workflow_v03',
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
videoSrc:
'https://media.comfy.org/website/learning/deaging_workflow_v03.mp4',
poster:
'https://media.comfy.org/website/learning/deaging_workflow_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=93f286fbc2c8',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'frame_adjustments_demo_v03',
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
videoSrc:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03.mp4',
poster:
'https://media.comfy.org/website/learning/frame_adjustments_demo_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'mattes_and_utilities_v03',
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
videoSrc:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03.mp4',
poster:
'https://media.comfy.org/website/learning/mattes_and_utilities_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=be0889296f65',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'seedance_demo_comfyui_v03',
title: { en: 'Seedance Demo ComfyUI', 'zh-CN': 'Seedance ComfyUI 演示' },
videoSrc:
'https://media.comfy.org/website/learning/seedance_demo_comfyui_v03.mp4',
poster:
'https://media.comfy.org/website/learning/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
tags: [partnerNodesTag, imageToVideoTag]
},
{
id: 'skyreplacement_smaller_v06',
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
videoSrc:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06.mp4',
poster:
'https://media.comfy.org/website/learning/skyreplacement_smaller_v06_thumbnail.jpg',
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
tags: [partnerNodesTag, imageToVideoTag]
}
] as const

View File

@@ -73,7 +73,7 @@ const websiteJsonLd = {
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="shortcut icon" href="/favicon.ico" sizes="48x48" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#211927" />

View File

@@ -3,14 +3,10 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/gallery/HeroSection.vue'
import GallerySection from '../components/gallery/GallerySection.vue'
import ContactSection from '../components/gallery/ContactSection.vue'
import { getVisibleGalleryByLocale } from '../content/queries'
const entries = await getVisibleGalleryByLocale('en')
const items = entries.map((entry) => entry.data)
---
<BaseLayout title="Gallery — Comfy">
<HeroSection />
<GallerySection items={items} client:load />
<GallerySection client:load />
<ContactSection />
</BaseLayout>

View File

@@ -8,20 +8,14 @@ import CallToActionSection from '../components/common/CallToActionSection.vue'
import { getRoutes } from '../config/routes'
import { externalLinks } from '../config/routes'
// import { learningEvents } from '../data/events'
import { getTutorialsByLocale, slugOf } from '../content/queries'
const routes = getRoutes('en')
const tutorialEntries = await getTutorialsByLocale('en')
const tutorials = tutorialEntries.map((entry) => ({
slug: slugOf(entry),
...entry.data
}))
---
<BaseLayout title="Learning — Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection tutorials={tutorials} client:visible />
<TutorialsSection client:visible />
<CallToActionSection
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"

View File

@@ -3,18 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
import { getGalleryByIds } from '../content/queries'
const featuredCreationSlugs = [
'subway-swan',
'milos-little-wonder',
'amber-passage',
'neon-revenant',
'midnight-umami'
]
const featuredEntries = await getGalleryByIds(featuredCreationSlugs, 'en')
const featuredItems = featuredEntries.map((entry) => entry.data)
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
---
<BaseLayout
@@ -27,7 +16,7 @@ const featuredItems = featuredEntries.map((entry) => entry.data)
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="Grok Imagine output created with ComfyUI"
/>
<ModelCreationsSection items={featuredItems} client:load />
<ModelCreationsSection client:load />
<AIModelsSection client:load />
<ProductShowcaseSection client:load />
</BaseLayout>

View File

@@ -3,14 +3,10 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/gallery/HeroSection.vue'
import GallerySection from '../../components/gallery/GallerySection.vue'
import ContactSection from '../../components/gallery/ContactSection.vue'
import { getVisibleGalleryByLocale } from '../../content/queries'
const entries = await getVisibleGalleryByLocale('zh-CN')
const items = entries.map((entry) => entry.data)
---
<BaseLayout title="作品集 — Comfy">
<HeroSection locale="zh-CN" />
<GallerySection locale="zh-CN" items={items} client:load />
<GallerySection locale="zh-CN" client:load />
<ContactSection locale="zh-CN" />
</BaseLayout>

View File

@@ -6,34 +6,15 @@ import TutorialsSection from '../../components/learning/TutorialsSection.vue'
import CallToActionSection from '../../components/common/CallToActionSection.vue'
import EventsSection from '../../components/common/EventsSection.vue'
import { getRoutes, externalLinks } from '../../config/routes'
import {
getEventsByLocale,
getTutorialsByLocale,
slugOf
} from '../../content/queries'
import { learningEvents } from '../../data/events'
const routes = getRoutes('zh-CN')
const eventEntries = await getEventsByLocale('zh-CN')
const events = eventEntries.map((entry) => entry.data)
const tutorialEntries = await getTutorialsByLocale('zh-CN')
const tutorials = tutorialEntries.map((entry) => ({
slug: slugOf(entry),
...entry.data
}))
---
<BaseLayout title="学习 — Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" tutorials={tutorials} client:visible />
<EventsSection
locale="zh-CN"
headingKey="learning.events.heading"
descriptionKey="learning.events.description"
notifyLabelKey="learning.events.getNotified"
events={events}
client:visible
/>
<TutorialsSection locale="zh-CN" client:visible />
<CallToActionSection
locale="zh-CN"
headingKey="learning.cta.heading"

View File

@@ -4,17 +4,6 @@ import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
import { getGalleryByIds } from '../../content/queries'
const featuredCreationSlugs = [
'subway-swan',
'milos-little-wonder',
'amber-passage',
'neon-revenant',
'midnight-umami'
]
const featuredEntries = await getGalleryByIds(featuredCreationSlugs, 'en')
const featuredItems = featuredEntries.map((entry) => entry.data)
---
<BaseLayout
@@ -28,7 +17,7 @@ const featuredItems = featuredEntries.map((entry) => entry.data)
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
/>
<ModelCreationsSection items={featuredItems} client:load locale="zh-CN" />
<ModelCreationsSection client:load locale="zh-CN" />
<AIModelsSection client:load locale="zh-CN" />
<ProductShowcaseSection client:load locale="zh-CN" />
</BaseLayout>

View File

@@ -53,3 +53,28 @@ describe('initPostHog', () => {
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})
describe('captureDownloadClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the download event with the platform', async () => {
const { initPostHog, captureDownloadClick } = await import('./posthog')
initPostHog()
captureDownloadClick('mac')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:download_button_clicked',
{ platform: 'mac' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureDownloadClick } = await import('./posthog')
captureDownloadClick('windows')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -38,3 +38,12 @@ export function capturePageview() {
console.error('PostHog pageview capture failed', error)
}
}
export function captureDownloadClick(platform: string) {
if (!initialized) return
try {
posthog.capture('website:download_button_clicked', { platform })
} catch (error) {
console.error('PostHog download click capture failed', error)
}
}

View File

@@ -1,40 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getTutorialPosterSrc } from './tutorial'
describe('getTutorialPosterSrc', () => {
it('returns the explicit poster URL when provided', () => {
expect(
getTutorialPosterSrc({
order: 1,
tags: [],
title: 'T',
videoSrc: 'https://example.com/v.mp4',
poster: 'https://example.com/poster.jpg'
})
).toBe('https://example.com/poster.jpg')
})
it('falls back to videoSrc#t=<posterTime> when poster is missing', () => {
expect(
getTutorialPosterSrc({
order: 1,
tags: [],
title: 'T',
videoSrc: 'https://example.com/v.mp4',
posterTime: 7
})
).toBe('https://example.com/v.mp4#t=7')
})
it('uses the default poster time when neither poster nor posterTime is set', () => {
expect(
getTutorialPosterSrc({
order: 1,
tags: [],
title: 'T',
videoSrc: 'https://example.com/v.mp4'
})
).toBe('https://example.com/v.mp4#t=1')
})
})

View File

@@ -1,8 +0,0 @@
import type { LearningTutorial } from '../content.config'
const DEFAULT_POSTER_TIME_SECONDS = 1
export const getTutorialPosterSrc = (tutorial: LearningTutorial): string =>
tutorial.poster
? tutorial.poster
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`

View File

@@ -0,0 +1,61 @@
{
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "E2E_OldSampler",
"pos": [100, 100],
"size": [400, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [42, 20, 7, "euler", "normal"]
},
{
"id": 2,
"type": "E2E_OldSampler",
"pos": [520, 100],
"size": [400, 262],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": null },
{ "name": "positive", "type": "CONDITIONING", "link": null },
{ "name": "negative", "type": "CONDITIONING", "link": null },
{ "name": "latent_image", "type": "LATENT", "link": null }
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": { "Node name for S&R": "E2E_OldSampler" },
"widgets_values": [43, 20, 7, "euler", "normal"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
"version": 0.4
}

View File

@@ -70,6 +70,7 @@ export const TestIds = {
missingModelImportUnsupported: 'missing-model-import-unsupported',
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
swapNodeGroupCount: 'swap-node-group-count',
missingMediaRow: 'missing-media-row',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',

View File

@@ -48,6 +48,36 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
).toBeVisible()
})
test('Shows direct row label and locate action for a single replacement group', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const rowLabel = swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
await expect(rowLabel).toBeVisible()
await expect(
swapGroup.getByRole('button', {
name: 'Locate node on canvas',
exact: true
})
).toBeVisible()
await expect(
swapGroup.getByTestId(TestIds.dialogs.swapNodeGroupCount)
).toHaveCount(0)
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await rowLabel.click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
.not.toEqual(offsetBeforeLocate)
})
test('Replace Node replaces a single group in-place', async ({
comfyPage
}) => {
@@ -116,6 +146,55 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
})
})
test.describe('Same-type replacement group', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
mode.vueNodesEnabled
)
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/node_replacement_same_type'
)
})
test('Groups same-type replacement rows behind the title disclosure', async ({
comfyPage
}) => {
const swapGroup = getSwapNodesGroup(comfyPage.page)
const countBadge = swapGroup.getByTestId(
TestIds.dialogs.swapNodeGroupCount
)
const childRows = swapGroup.getByRole('listitem')
const expandButton = swapGroup.getByRole('button', {
name: 'Expand E2E_OldSampler',
exact: true
})
await expect(expandButton).toBeVisible()
await expect(countBadge).toHaveText('2')
await expect(childRows).toHaveCount(0)
await expandButton.click()
await expect(childRows).toHaveCount(2)
await expect(
swapGroup.getByRole('button', {
name: 'E2E_OldSampler',
exact: true
})
).toHaveCount(2)
await swapGroup
.getByRole('button', {
name: 'Collapse E2E_OldSampler',
exact: true
})
.click()
await expect(childRows).toHaveCount(0)
})
})
test.describe('Multi-type replacement', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(

View File

@@ -421,15 +421,6 @@ export default defineConfig([
}
},
// Astro virtual modules (astro:content, astro:assets, etc.) are not
// resolvable by the TS resolver — they are injected by the Astro build.
{
files: ['apps/website/**/*.{ts,vue,astro}'],
rules: {
'import-x/no-unresolved': ['error', { ignore: ['^astro:'] }]
}
},
// i18n import enforcement
// Vue components must use the useI18n() composable, not the global t/d/st/te
{

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.12",
"version": "1.46.13",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -111,6 +111,7 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
})

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