Compare commits

..

13 Commits

Author SHA1 Message Date
Alexander Brown
0ab5f10670 Merge branch 'main' into austin/named-widget-values 2026-07-10 10:20:16 -07:00
Terry Jia
4ed2fe70f3 feat: accept bboxes input and add grid snapping to Create Bounding Boxes (#13376)
## Summary
- Seed/override the canvas from an upstream bboxes input
- Add dotted grid background and magnetic snap-to-grid controls
- Darken the canvas well for contrast

BE is https://github.com/Comfy-Org/ComfyUI/pull/14724

## Screenshots (if applicable)



https://github.com/user-attachments/assets/7282f4d5-7cac-46f0-9d73-d0add37f4eb9
2026-07-10 15:40:17 +00:00
Terry Jia
5da5ee5031 feat: wire up Save 3D (Advanced) node family (CORE-329) (#13330)
## Summary
Register the save-side advanced nodes in the Load3D viewer
infrastructure: Save3DAdvanced reuses the mesh advanced extension, while
SaveGaussianSplat and SavePointCloud reuse the splat/point cloud preview
extensions.

Parameterize both extension factories with a loadFolder so save nodes
load the persisted file from the output folder instead of temp, and add
the node types to the lazy-load and viewport-state sets.

BE change https://github.com/Comfy-Org/ComfyUI/pull/14701
## Screenshots (if applicable)
Save 3D (Advanced)
<img width="1328" height="939" alt="image"
src="https://github.com/user-attachments/assets/c5f3cbe0-6e57-463c-9128-67490c2fc89e"
/>

Save Splat
<img width="1296" height="1052" alt="image"
src="https://github.com/user-attachments/assets/f05bbcf8-9794-4861-9dd7-3015d38b11d9"
/>
2026-07-10 15:39:51 +00:00
Austin Mroz
e0f493b559 Merge main 2026-07-02 12:59:11 -07:00
Austin
5e7e3b0a35 Refactor naming to have less reuse 2026-04-30 15:04:25 -07:00
Austin
20ecf6ed97 Review feedback 2026-04-28 10:12:14 -07:00
Austin Mroz
1a6f338a54 Add test for serialization 2026-04-28 10:12:14 -07:00
Austin Mroz
2dc31526e5 Rework minsize code to better support tests 2026-04-28 10:12:14 -07:00
Austin Mroz
c4fa476df0 Review feedback 2026-04-28 10:12:14 -07:00
Austin Mroz
7d9c35f306 Add tests 2026-04-28 10:12:12 -07:00
Austin Mroz
b495a1ceaf Guard load by named values behind setting 2026-04-28 10:08:33 -07:00
Austin Mroz
f62a817213 Trigger widget.callback when loading 2026-04-28 10:08:33 -07:00
Austin Mroz
20203ed41c Implement widgets_values_named for serialization 2026-04-28 10:08:33 -07:00
63 changed files with 1641 additions and 1179 deletions

View File

@@ -39,9 +39,3 @@ export const NoBackdrop: Story = {
backdrop: undefined
}
}
export const DarkTone: Story = {
args: {
tone: 'dark'
}
}

View File

@@ -20,7 +20,6 @@ const {
title,
subtitle,
footnote,
tone = 'light',
class: className
} = defineProps<{
backdrop?: Backdrop
@@ -31,19 +30,9 @@ const {
title: string
subtitle?: string
footnote?: string
tone?: 'light' | 'dark'
class?: HTMLAttributes['class']
}>()
// Light tone assumes a light backdrop: dark ink text on desktop plus a
// warm-white radial scrim for contrast. Dark tone assumes a dark backdrop
// and uses white text at all breakpoints, with no scrim.
const toneTextClass = computed(() =>
tone === 'light'
? 'text-primary-comfy-canvas lg:text-primary-comfy-ink'
: 'text-white'
)
// Respect prefers-reduced-motion: don't autoplay the looping backdrop video
// (WCAG 2.2.2). The paused video falls back to its poster/first frame.
const reduceMotion = computed(() => prefersReducedMotion())
@@ -162,7 +151,6 @@ const scrimStyle = {
>
<div class="relative w-full max-w-xl">
<div
v-if="tone === 'light'"
aria-hidden="true"
class="pointer-events-none absolute -inset-12 hidden backdrop-blur-md lg:-inset-16 lg:block"
:style="scrimStyle"
@@ -177,24 +165,22 @@ const scrimStyle = {
/>
<h1
:class="
cn(
'mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line lg:text-6xl/tight',
toneTextClass
)
"
class="mt-10 text-4xl/tight font-light tracking-tight whitespace-pre-line text-primary-comfy-canvas lg:text-6xl/tight lg:text-primary-comfy-ink"
>
{{ title }}
</h1>
<p
v-if="subtitle"
:class="cn('mt-8 max-w-md text-base lg:text-lg', toneTextClass)"
class="mt-8 max-w-md text-base text-primary-comfy-canvas lg:text-lg lg:text-primary-comfy-ink"
>
{{ subtitle }}
</p>
<p v-if="footnote" :class="cn('mt-10 text-sm', toneTextClass)">
<p
v-if="footnote"
class="mt-10 text-sm text-primary-comfy-canvas lg:text-primary-comfy-ink"
>
{{ footnote }}
</p>

View File

@@ -1,18 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { clientLogos } from '../../data/clientLogos'
import LogosAll01 from './LogosAll01.vue'
const meta: Meta<typeof LogosAll01> = {
title: 'Website/Blocks/LogosAll01',
component: LogosAll01,
tags: ['autodocs'],
args: {
logos: clientLogos
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}

View File

@@ -1,83 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
export interface LogoItem {
src: string
alt: string
}
const { logos, testId = 'logos-all' } = defineProps<{
logos: readonly LogoItem[]
testId?: string
}>()
// On mobile the single row splits into two counter-scrolling halves.
const mobileRow1Logos = computed(() =>
logos.slice(0, Math.ceil(logos.length / 2))
)
const mobileRow2Logos = computed(() => logos.slice(Math.ceil(logos.length / 2)))
</script>
<template>
<section class="overflow-hidden py-12">
<!-- Single row on desktop -->
<div :data-testid="`${testId}-desktop`" class="hidden w-max gap-2 md:flex">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-2"
style="--marquee-gap: 0.5rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="logo in logos"
:key="logo.src"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<img :src="logo.src" :alt="logo.alt" />
</div>
</div>
</div>
<!-- Two rows on mobile -->
<div
:data-testid="`${testId}-mobile`"
class="flex flex-col gap-8 md:hidden"
>
<div class="flex w-max gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="logo in mobileRow1Logos"
:key="logo.src"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="logo.src" :alt="logo.alt" />
</div>
</div>
</div>
<div class="flex w-max gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="logo in mobileRow2Logos"
:key="logo.src"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="logo.src" :alt="logo.alt" />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -25,7 +25,7 @@ const {
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
<div class="flex flex-col items-center text-center">
<h2
class="max-w-5xl text-3xl font-light tracking-tight text-balance text-primary-comfy-canvas lg:text-5xl"
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ t(headingKey, locale) }}
</h2>

View File

@@ -1,8 +1,82 @@
<script setup lang="ts">
import { clientLogos } from '../../data/clientLogos'
import LogosAll01 from '../blocks/LogosAll01.vue'
const logos = [
'Amazon Studios',
'Apple',
'Autodesk',
'Harman',
'Hp',
'Lucid',
'Netflix',
'Nike',
'Pixomondo',
'Tencent',
'Ubisoft'
]
const mobileRow1Logos = logos.slice(0, 6)
const mobileRow2Logos = logos.slice(6)
</script>
<template>
<LogosAll01 :logos="clientLogos" test-id="social-proof" />
<section class="overflow-hidden py-12">
<!-- Single row on desktop -->
<div data-testid="social-proof-desktop" class="hidden w-max gap-2 md:flex">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-2"
style="--marquee-gap: 0.5rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="logo in logos"
:key="logo"
class="flex h-20 w-50 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>
<!-- Two rows on mobile -->
<div
data-testid="social-proof-mobile"
class="flex flex-col gap-8 md:hidden"
>
<div class="flex w-max gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="logo in mobileRow1Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>
<div class="flex w-max gap-8">
<div
v-for="copy in 2"
:key="copy"
class="animate-marquee-reverse flex shrink-0 items-center gap-8"
style="--marquee-gap: 2rem"
:aria-hidden="copy === 2 ? 'true' : undefined"
>
<div
v-for="logo in mobileRow2Logos"
:key="logo"
class="flex h-14 w-40 shrink-0 items-center justify-center"
>
<img :src="`/icons/clients/${logo}.svg`" :alt="logo" />
</div>
</div>
</div>
</div>
</section>
</template>

View File

@@ -1,30 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { vfxTutorials } from '../../data/vfxTutorials'
import TutorialCard from './TutorialCard.vue'
const meta: Meta<typeof TutorialCard> = {
title: 'Website/Common/TutorialCard',
component: TutorialCard,
tags: ['autodocs'],
args: {
tutorial: vfxTutorials[0],
titlePrefixKey: 'learning.tutorials.titlePrefix'
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Overlay: Story = {
args: {
variant: 'overlay',
titlePrefixKey: undefined,
tutorial: {
...vfxTutorials[0],
author: { name: 'Author Name', avatarSrc: '/icons/clients/Apple.svg' }
}
}
}

View File

@@ -1,180 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { VideoTrack } from './VideoPlayer.vue'
import type {
Locale,
LocalizedText,
TranslationKey
} from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Badge from '../ui/badge/Badge.vue'
import { ButtonMask } from '../ui/button-mask'
export interface Tutorial {
id: string
tags: readonly TranslationKey[]
title: LocalizedText
videoSrc: string
href?: string
poster?: string
caption?: readonly VideoTrack[]
posterTime?: number
author?: { name: string; avatarSrc?: string }
}
const DEFAULT_POSTER_TIME_SECONDS = 1
const getTutorialPosterSrc = (tutorial: Tutorial): string =>
tutorial.poster
? tutorial.poster
: `${tutorial.videoSrc}#t=${tutorial.posterTime ?? DEFAULT_POSTER_TIME_SECONDS}`
const {
tutorial,
locale = 'en',
titlePrefixKey,
variant = 'default'
} = defineProps<{
tutorial: Tutorial
locale?: Locale
titlePrefixKey?: TranslationKey
variant?: 'default' | 'overlay'
}>()
const emit = defineEmits<{ play: [] }>()
const accessibleTitle = computed(() =>
titlePrefixKey
? `${t(titlePrefixKey, locale)} ${tutorial.title[locale]}`
: tutorial.title[locale]
)
</script>
<template>
<article
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="accessibleTitle"
@click="emit('play')"
>
<video
:src="getTutorialPosterSrc(tutorial)"
:poster="tutorial.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<!-- Dim the thumbnail so the overlaid title stays legible. -->
<span
v-if="variant === 'overlay'"
class="absolute inset-0 bg-black/30"
aria-hidden="true"
/>
<span
v-if="variant === 'overlay'"
class="absolute inset-x-0 top-0 h-1/2 bg-linear-to-b from-black/60 to-transparent"
aria-hidden="true"
/>
<span
v-if="variant === 'overlay'"
class="pointer-events-none absolute inset-x-0 top-0 p-6 text-left text-2xl font-light text-primary-comfy-canvas lg:p-8 lg:text-3xl"
>
{{ tutorial.title[locale] }}
</span>
<span
class="absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<span
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
>
<svg
class="ml-1 size-5 text-white lg:size-6"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</button>
<div class="flex flex-col space-y-3 p-4">
<div
v-if="variant === 'overlay'"
class="flex items-center justify-between gap-4"
>
<span
v-if="tutorial.author"
class="flex min-w-0 items-center gap-3 text-sm text-primary-comfy-canvas lg:text-base"
>
<img
v-if="tutorial.author.avatarSrc"
:src="tutorial.author.avatarSrc"
alt=""
class="size-10 shrink-0 rounded-full object-cover"
/>
<span class="truncate">{{ tutorial.author.name }}</span>
</span>
<ButtonMask
v-if="tutorial.href"
as="a"
:href="tutorial.href"
icon-position="left"
:hide-label="false"
class="ml-auto shrink-0 ps-12"
variant="ghost"
size="default"
>
{{ t('cta.tryWorkflow', locale) }}
</ButtonMask>
</div>
<div v-else class="flex items-center justify-between gap-4">
<h3 class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug">
<template v-if="titlePrefixKey"
>{{ t(titlePrefixKey, locale) }}<br
/></template>
{{ tutorial.title[locale] }}
</h3>
<ButtonMask
v-if="tutorial.href"
as="a"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="default"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</ButtonMask>
</div>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ t(tag, locale) }}</Badge>
</li>
</ul>
</div>
</article>
</template>

View File

@@ -1,21 +1,16 @@
<script setup lang="ts">
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
import type { Tutorial } from './TutorialCard.vue'
import type { Locale, TranslationKey } from '../../i18n/translations'
import type { LearningTutorial } from '../../data/learningTutorials'
import type { Locale } from '../../i18n/translations'
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
import { t } from '../../i18n/translations'
import VideoPlayer from './VideoPlayer.vue'
import VideoPlayer from '../common/VideoPlayer.vue'
const {
tutorial,
locale = 'en',
titlePrefixKey
} = defineProps<{
tutorial: Tutorial
const { tutorial, locale = 'en' } = defineProps<{
tutorial: LearningTutorial
locale?: Locale
titlePrefixKey?: TranslationKey
}>()
const emit = defineEmits<{ close: [] }>()
@@ -78,9 +73,7 @@ onUnmounted(() => {
<h2
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
>
<template v-if="titlePrefixKey"
>{{ t(titlePrefixKey, locale) }}
</template>
{{ t('learning.tutorials.titlePrefix', locale) }}
{{ tutorial.title[locale] }}
</h2>
</dialog>

View File

@@ -3,10 +3,14 @@ import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { learningTutorials } from '../../data/learningTutorials'
import {
getTutorialPosterSrc,
learningTutorials
} from '../../data/learningTutorials'
import { t } from '../../i18n/translations'
import TutorialCard from '../common/TutorialCard.vue'
import TutorialDetailDialog from '../common/TutorialDetailDialog.vue'
import Badge from '../ui/badge/Badge.vue'
import { ButtonMask } from '../ui/button-mask'
import TutorialDetailDialog from './TutorialDetailDialog.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -26,13 +30,84 @@ const activeTutorial = () =>
<ul
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 learningTutorials" :key="tutorial.id">
<TutorialCard
:tutorial="tutorial"
:locale="locale"
title-prefix-key="learning.tutorials.titlePrefix"
@play="activeTutorialId = tutorial.id"
/>
<li
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[locale]}`"
@click="activeTutorialId = tutorial.id"
>
<video
:src="getTutorialPosterSrc(tutorial)"
:poster="tutorial.poster"
class="size-full object-cover"
preload="metadata"
playsinline
muted
></video>
<span
class="absolute inset-0 flex items-center justify-center"
aria-hidden="true"
>
<span
class="flex size-14 items-center justify-center rounded-full bg-white/25 backdrop-blur-sm transition-transform group-hover:scale-105 lg:size-16"
>
<svg
class="ml-1 size-5 text-white lg:size-6"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 5v14l11-7z" />
</svg>
</span>
</span>
</button>
<div class="flex flex-col space-y-3 p-4">
<div class="flex items-center justify-between gap-4">
<h3
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
>
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
{{ tutorial.title[locale] }}
</h3>
<ButtonMask
v-if="tutorial.href"
as="a"
:href="tutorial.href"
icon-position="right"
class="shrink-0"
variant="ghost"
size="default"
>
{{ t('cta.tryWorkflow', locale) }}
<template #icon>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<polyline points="9 6 15 12 9 18" />
</svg>
</template>
</ButtonMask>
</div>
<ul class="flex flex-wrap gap-2">
<li v-for="tag in tutorial.tags" :key="tag">
<Badge>{{ t(tag, locale) }}</Badge>
</li>
</ul>
</div>
</li>
</ul>
@@ -40,7 +115,6 @@ const activeTutorial = () =>
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
title-prefix-key="learning.tutorials.titlePrefix"
@close="activeTutorialId = null"
/>
</section>

View File

@@ -1,43 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Locale } from '../../i18n/translations'
import { externalLinks, getRoutes } from '../../config/routes'
import { t } from '../../i18n/translations'
import HeroBackdrop01 from '../blocks/HeroBackdrop01.vue'
import Button from '../ui/button/Button.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const routes = computed(() => getRoutes(locale))
const backdrop = {
type: 'video',
src: 'https://media.comfy.org/website/vfx/vfx_hero.mp4'
} as const
const mobileBackdrop = {
type: 'video',
src: 'https://media.comfy.org/website/vfx/vfx_hero-mobile.mp4'
} as const
</script>
<template>
<HeroBackdrop01
tone="dark"
:backdrop="backdrop"
:mobile-backdrop="mobileBackdrop"
:title="t('vfx.hero.title', locale)"
:subtitle="t('vfx.hero.subtitle', locale)"
>
<div class="mt-10 flex flex-wrap gap-4">
<Button as="a" :href="routes.contact">
{{ t('vfx.cta.contactSales', locale) }}
</Button>
<Button as="a" :href="externalLinks.cloud" variant="outline">
{{ t('vfx.hero.tryNow', locale) }}
</Button>
</div>
</HeroBackdrop01>
</template>

View File

@@ -1,52 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { vfxTutorials } from '../../data/vfxTutorials'
import { t } from '../../i18n/translations'
import TutorialCard from '../common/TutorialCard.vue'
import TutorialDetailDialog from '../common/TutorialDetailDialog.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const activeTutorialId = ref<string | null>(null)
const activeTutorial = () =>
vfxTutorials.find((tutorial) => tutorial.id === activeTutorialId.value)
</script>
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<h2
class="mb-6 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{ t('vfx.tutorials.heading', locale) }}
</h2>
<p
class="mx-auto mb-12 max-w-3xl text-center text-base text-primary-comfy-canvas lg:mb-16 lg:text-lg"
>
{{ t('vfx.tutorials.description', locale) }}
</p>
<ul
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 vfxTutorials" :key="tutorial.id">
<TutorialCard
:tutorial="tutorial"
:locale="locale"
variant="overlay"
@play="activeTutorialId = tutorial.id"
/>
</li>
</ul>
<TutorialDetailDialog
v-if="activeTutorial()"
:tutorial="activeTutorial()!"
:locale="locale"
@close="activeTutorialId = null"
/>
</section>
</template>

View File

@@ -14,7 +14,6 @@ const baseRoutes = {
customers: '/customers',
demos: '/demos',
learning: '/learning',
vfx: '/learning/vfx',
termsOfService: '/terms-of-service',
enterpriseMsa: '/enterprise-msa',
privacyPolicy: '/privacy-policy',

View File

@@ -1,20 +0,0 @@
import type { LogoItem } from '../components/blocks/LogosAll01.vue'
const names = [
'Amazon Studios',
'Apple',
'Autodesk',
'Harman',
'Hp',
'Lucid',
'Netflix',
'Nike',
'Pixomondo',
'Tencent',
'Ubisoft'
]
export const clientLogos: readonly LogoItem[] = names.map((name) => ({
src: `/icons/clients/${name}.svg`,
alt: name
}))

View File

@@ -1,10 +1,28 @@
import type { Tutorial } from '../components/common/TutorialCard.vue'
import type { TranslationKey } from '../i18n/translations'
import type { VideoTrack } from '../components/common/VideoPlayer.vue'
import type { LocalizedText, TranslationKey } from '../i18n/translations'
export interface LearningTutorial {
id: string
tags: readonly TranslationKey[]
title: LocalizedText
videoSrc: string
href?: string
poster?: string
caption?: readonly VideoTrack[]
posterTime?: number
}
const DEFAULT_POSTER_TIME_SECONDS = 1
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
export const learningTutorials: readonly Tutorial[] = [
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': '净板演练' },

View File

@@ -1,129 +0,0 @@
import type { Tutorial } from '../components/common/TutorialCard.vue'
import type { TranslationKey } from '../i18n/translations'
const partnerNodesTag: TranslationKey = 'tags.partnerNodes'
const imageToVideoTag: TranslationKey = 'tags.imageToVideo'
const dougHoman: Tutorial['author'] = {
name: 'Doug Homan',
avatarSrc: 'https://media.comfy.org/website/authors/doug-hogan.jpeg'
}
export const vfxTutorials: readonly Tutorial[] = [
{
id: 'skyreplacement_smaller_v06',
title: { en: 'Sky Replacement', 'zh-CN': '天空替换' },
videoSrc:
'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06.mp4',
poster:
'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06_thumbnail.jpg',
href: 'https://comfy.org/workflows/537cf7f1f745-537cf7f1f745/',
caption: [
{
src: 'https://media.comfy.org/website/vfx/skyreplacement_smaller_v06_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag],
author: dougHoman
},
{
id: 'cleanplate_walkthrough_v03',
title: { en: 'Cleanplate Walkthrough', 'zh-CN': '净板演练' },
videoSrc:
'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03.mp4',
poster:
'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03_thumbnail.jpg',
caption: [
{
src: 'https://media.comfy.org/website/vfx/cleanplate_walkthrough_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
href: 'https://comfy.org/workflows/8f2cf0df5da6-8f2cf0df5da6/',
tags: [partnerNodesTag, imageToVideoTag],
author: dougHoman
},
{
id: 'deaging_workflow_v03',
title: { en: 'Deaging Workflow', 'zh-CN': '减龄工作流' },
videoSrc: 'https://media.comfy.org/website/vfx/deaging_workflow_v03.mp4',
poster:
'https://media.comfy.org/website/vfx/deaging_workflow_v03_thumbnail.jpg',
href: 'https://comfy.org/workflows/93f286fbc2c8-93f286fbc2c8/',
caption: [
{
src: 'https://media.comfy.org/website/vfx/deaging_workflow_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag],
author: dougHoman
},
{
id: 'frame_adjustments_demo_v03',
title: { en: 'Frame Adjustments Demo', 'zh-CN': '帧调整演示' },
videoSrc:
'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03.mp4',
poster:
'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=7dca0438edf4',
caption: [
{
src: 'https://media.comfy.org/website/vfx/frame_adjustments_demo_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag],
author: dougHoman
},
{
id: 'mattes_and_utilities_v03',
title: { en: 'Mattes and Utilities', 'zh-CN': '遮罩与实用工具' },
videoSrc:
'https://media.comfy.org/website/vfx/mattes_and_utilities_v03.mp4',
poster:
'https://media.comfy.org/website/vfx/mattes_and_utilities_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=be0889296f65',
caption: [
{
src: 'https://media.comfy.org/website/vfx/mattes_and_utilities_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag],
author: dougHoman
},
{
id: 'seedance_demo_comfyui_v03',
title: {
en: 'Seedance Demo (ComfyUI)',
'zh-CN': 'Seedance 演示ComfyUI'
},
videoSrc:
'https://media.comfy.org/website/vfx/seedance_demo_comfyui_v03.mp4',
poster:
'https://media.comfy.org/website/vfx/seedance seedance_demo_comfyui_v03_thumbnail.jpg',
href: 'https://cloud.comfy.org/?share=ef543bd4a773',
caption: [
{
src: 'https://media.comfy.org/website/vfx/seedance_demo_comfyui_v03_vtt.en.vtt',
kind: 'captions',
srclang: 'en',
label: 'English'
}
],
tags: [partnerNodesTag, imageToVideoTag],
author: dougHoman
}
] as const

View File

@@ -1546,42 +1546,6 @@ const translations = {
},
'learning.events.getNotified': { en: 'Get Notified', 'zh-CN': '获取通知' },
// VfxHeroSection
'vfx.hero.title': {
en: 'ComfyUI for\nVisual Effects',
'zh-CN': '面向视觉特效的\nComfyUI'
},
'vfx.hero.subtitle': {
en: 'From ideation to production, ComfyUI gives VFX teams faster workflows and more controlled output — so what ships looks exactly like what you envisioned.',
'zh-CN':
'从创意到成片ComfyUI 为视觉特效团队带来更快的工作流与更可控的产出——让最终交付与你的构想分毫不差。'
},
'vfx.hero.tryNow': {
en: 'Try Now',
'zh-CN': '立即体验'
},
// VfxTutorialsSection
'vfx.tutorials.heading': {
en: 'See Whats Possible',
'zh-CN': '见证无限可能'
},
'vfx.tutorials.description': {
en: 'From concept, storyboarding to texture generation and compositing references — discover workflows built for every stage of VFX production. New workflows added regularly.',
'zh-CN':
'从概念设计、分镜脚本到纹理生成与合成参考——探索覆盖视觉特效制作各个环节的工作流。新工作流持续上新。'
},
// VfxCallToActionSection
'vfx.cta.heading': {
en: 'Bring your ideas to life with ComfyUI, exactly as intended.',
'zh-CN': '用 ComfyUI 将创意变为现实,一切尽如所想。'
},
'vfx.cta.contactSales': {
en: 'Contact Sales',
'zh-CN': '联系销售'
},
// GalleryHeroSection
'gallery.label': { en: 'GALLERY', 'zh-CN': '画廊' },
'gallery.heroTitle.before': {

View File

@@ -0,0 +1,27 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/learning/HeroSection.vue'
import FeaturedWorkflowSection from '../components/learning/FeaturedWorkflowSection.vue'
import TutorialsSection from '../components/learning/TutorialsSection.vue'
import CallToActionSection from '../components/common/CallToActionSection.vue'
// import EventsSection from '../components/common/EventsSection.vue'
import { getRoutes } from '../config/routes'
import { externalLinks } from '../config/routes'
// import { learningEvents } from '../data/events'
const routes = getRoutes('en')
---
<BaseLayout title="Learning — Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />
<CallToActionSection
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={externalLinks.workflows}
client:visible
/>
</BaseLayout>

View File

@@ -1,25 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import HeroSection from '../../components/vfx/HeroSection.vue'
import TutorialsSection from '../../components/vfx/TutorialsSection.vue'
import SocialProofBarSection from '../../components/common/SocialProofBarSection.vue'
import CallToActionSection from '../../components/common/CallToActionSection.vue'
import { getRoutes } from '../../config/routes'
import { externalLinks } from '../../config/routes'
const routes = getRoutes('en')
---
<BaseLayout title="VFX — Comfy">
<HeroSection client:load />
<SocialProofBarSection client:visible />
<TutorialsSection client:visible />
<CallToActionSection
headingKey="vfx.cta.heading"
primaryLabelKey="vfx.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={externalLinks.workflows}
client:visible
/>
</BaseLayout>

View File

@@ -4,19 +4,19 @@ import HeroSection from '../../components/learning/HeroSection.vue'
import FeaturedWorkflowSection from '../../components/learning/FeaturedWorkflowSection.vue'
import TutorialsSection from '../../components/learning/TutorialsSection.vue'
import CallToActionSection from '../../components/common/CallToActionSection.vue'
// import EventsSection from '../../components/common/EventsSection.vue'
import { getRoutes } from '../../config/routes'
import { externalLinks } from '../../config/routes'
// import { learningEvents } from '../../data/events'
import EventsSection from '../../components/common/EventsSection.vue'
import { getRoutes, externalLinks } from '../../config/routes'
import { learningEvents } from '../../data/events'
const routes = getRoutes('en')
const routes = getRoutes('zh-CN')
---
<BaseLayout title="Learning — Comfy">
<HeroSection client:load />
<FeaturedWorkflowSection client:visible />
<TutorialsSection client:visible />
<BaseLayout title="学习 — Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" client:visible />
<CallToActionSection
locale="zh-CN"
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"
primaryHref={routes.contact}

View File

@@ -1,27 +0,0 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import HeroSection from '../../../components/learning/HeroSection.vue'
import FeaturedWorkflowSection from '../../../components/learning/FeaturedWorkflowSection.vue'
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 { learningEvents } from '../../../data/events'
const routes = getRoutes('zh-CN')
---
<BaseLayout title="学习 — Comfy">
<HeroSection locale="zh-CN" client:load />
<FeaturedWorkflowSection locale="zh-CN" client:visible />
<TutorialsSection locale="zh-CN" client:visible />
<CallToActionSection
locale="zh-CN"
headingKey="learning.cta.heading"
primaryLabelKey="learning.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={externalLinks.workflows}
client:visible
/>
</BaseLayout>

View File

@@ -1,25 +0,0 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro'
import HeroSection from '../../../components/vfx/HeroSection.vue'
import TutorialsSection from '../../../components/vfx/TutorialsSection.vue'
import SocialProofBarSection from '../../../components/common/SocialProofBarSection.vue'
import CallToActionSection from '../../../components/common/CallToActionSection.vue'
import { getRoutes, externalLinks } from '../../../config/routes'
const routes = getRoutes('zh-CN')
---
<BaseLayout title="视效 — Comfy">
<HeroSection locale="zh-CN" client:load />
<SocialProofBarSection client:visible />
<TutorialsSection locale="zh-CN" client:visible />
<CallToActionSection
locale="zh-CN"
headingKey="vfx.cta.heading"
primaryLabelKey="vfx.cta.contactSales"
primaryHref={routes.contact}
secondaryLabelKey="cta.tryWorkflow"
secondaryHref={externalLinks.workflows}
client:visible
/>
</BaseLayout>

View File

@@ -317,7 +317,13 @@ export const zComfyNodeDef = z.object({
/** Category for the Essentials tab. If set, the node appears in Essentials. */
essentials_category: z.string().optional(),
/** Whether the blueprint is a global/installed blueprint (not user-created). */
isGlobal: z.boolean().optional()
isGlobal: z.boolean().optional(),
/**
* An optional ordered list of widget names to be used when migrating a
* workflow that lacks widgets_values_named. Only useful if the order of
* widgets is changed after the introduction of widgets_values_named.
*/
fallbackWidgetsValuesNames: z.array(z.string()).optional()
})
export const zAutogrowOptions = z.object({

View File

@@ -4,37 +4,67 @@
data-testid="bounding-boxes"
@pointerdown.stop
>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
<div class="flex flex-col">
<div
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
>
<Button
variant="textonly"
size="unset"
:aria-pressed="grid"
:class="
cn(
actionBtnClass,
grid && 'bg-component-node-widget-background-selected'
)
"
@click="grid = !grid"
>
<i class="icon-[lucide--grid-3x3] size-4" />
<span>{{ $t('boundingBoxes.grid') }}</span>
</Button>
<Button
variant="textonly"
size="unset"
:class="cn(actionBtnClass, 'ml-auto')"
@click="clearAll"
>
<i class="icon-[lucide--undo-2] size-4" />
<span>{{ $t('boundingBoxes.clearAll') }}</span>
</Button>
</div>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm text-node-component-slot-text outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
</div>
</div>
<div
@@ -122,16 +152,6 @@
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
{{ $t('boundingBoxes.clickRegionToEdit') }}
</div>
<Button
variant="secondary"
size="md"
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
@click="clearAll"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('boundingBoxes.clearAll') }}
</Button>
</div>
</template>
@@ -147,6 +167,9 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { NodeId } from '@/types/nodeId'
const actionBtnClass =
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
const { nodeId } = defineProps<{ nodeId: NodeId }>()
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
@@ -172,7 +195,8 @@ const {
commitInlineEditor,
setActiveType,
clearAll,
syncState
syncState,
grid
} = useBoundingBoxes(nodeId, {
canvasEl,
canvasContainer,

View File

@@ -32,7 +32,7 @@ describe('PaletteSwatchRow', () => {
it('appends a color when the add button is clicked', async () => {
const { emitted } = renderRow(['#ff0000'])
await userEvent.click(screen.getByRole('button'))
await userEvent.click(screen.getByRole('button', { name: '+' }))
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
})
@@ -44,18 +44,14 @@ describe('PaletteSwatchRow', () => {
it('hides the add button once the max is reached', () => {
renderRow(['#a', '#b'], 2)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
})
it('writes a picked color back through the hidden color input', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.click(container.querySelector('[data-index="1"]')!)
const input = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#0000ff'
await fireEvent.input(input)
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
it('opens the color picker when a swatch is clicked', async () => {
const { container } = renderRow(['#ff0000'])
const swatch = container.querySelector('[data-index="0"]')!
await userEvent.click(swatch)
expect(swatch.getAttribute('data-state')).toBe('open')
})
it('starts a drag on pointer down without emitting', async () => {

View File

@@ -1,17 +1,25 @@
<template>
<div ref="container" class="flex flex-wrap items-center gap-1">
<div
<ColorPicker
v-for="(hex, i) in modelValue"
:key="`${i}-${hex}`"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@click="openPicker(i, $event)"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
:key="i"
:model-value="hex"
:alpha="false"
@update:model-value="(value) => updateAt(i, value)"
>
<template #trigger>
<button
type="button"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border p-0"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
</template>
</ColorPicker>
<button
v-if="modelValue.length < max"
type="button"
@@ -21,12 +29,6 @@
>
+
</button>
<input
ref="picker"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
@input="onPickerInput"
/>
</div>
</template>
@@ -34,6 +36,7 @@
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
const { max = 5 } = defineProps<{ max?: number }>()
@@ -41,8 +44,9 @@ const modelValue = defineModel<string[]>({ required: true })
const { t } = useI18n()
const container = useTemplateRef<HTMLDivElement>('container')
const picker = useTemplateRef<HTMLInputElement>('picker')
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
usePaletteSwatchRow({ modelValue, container, picker })
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
modelValue,
container
})
</script>

View File

@@ -14,20 +14,27 @@ import { cn } from '@comfyorg/tailwind-utils'
import ColorPickerPanel from './ColorPickerPanel.vue'
defineProps<{
const { alpha = true } = defineProps<{
class?: string
disabled?: boolean
alpha?: boolean
}>()
const modelValue = defineModel<string>({ default: '#000000' })
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
function readHsva(hex: string): HSVA {
const next = hexToHsva(hex || '#000000')
if (!alpha) next.a = 100
return next
}
const hsva = ref<HSVA>(readHsva(modelValue.value))
const displayMode = ref<'hex' | 'rgba'>('hex')
watch(modelValue, (newVal) => {
const current = hsvaToHex(hsva.value)
if (newVal !== current) {
hsva.value = hexToHsva(newVal || '#000000')
hsva.value = readHsva(newVal)
}
})
@@ -67,49 +74,51 @@ const contentStyle = useModalLiftedZIndex(isOpen)
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<button
type="button"
:disabled="$props.disabled"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
<slot name="trigger">
<button
type="button"
:disabled="$props.disabled"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</slot>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
@@ -123,6 +132,7 @@ const contentStyle = useModalLiftedZIndex(isOpen)
<ColorPickerPanel
v-model:hsva="hsva"
v-model:display-mode="displayMode"
:alpha
/>
</PopoverContent>
</PopoverPortal>

View File

@@ -13,6 +13,8 @@ import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
import ColorPickerSlider from './ColorPickerSlider.vue'
const { alpha = true } = defineProps<{ alpha?: boolean }>()
const hsva = defineModel<HSVA>('hsva', { required: true })
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
required: true
@@ -37,6 +39,7 @@ const { t } = useI18n()
/>
<ColorPickerSlider v-model="hsva.h" type="hue" />
<ColorPickerSlider
v-if="alpha"
v-model="hsva.a"
type="alpha"
:hue="hsva.h"
@@ -72,7 +75,7 @@ const { t } = useI18n()
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
</template>
<span class="shrink-0 border-l border-border-subtle pl-1"
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
>{{ hsva.a }}%</span
>
</div>

View File

@@ -156,7 +156,7 @@ describe('fromBoundingBoxes', () => {
y: 200,
width: 300,
height: 400,
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#ffffff'] }
}
]
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
@@ -167,10 +167,31 @@ describe('fromBoundingBoxes', () => {
type: 'text',
text: 'hi',
desc: 'd',
palette: ['#fff']
palette: ['#ffffff']
})
})
it('normalizes palette entries and drops invalid colors', () => {
const boxes: BoundingBox[] = [
{
x: 0,
y: 0,
width: 10,
height: 10,
metadata: {
type: 'obj',
text: '',
desc: '',
palette: ['#FF0000', '#abc', 'red', '', 123] as unknown as string[]
}
}
]
expect(fromBoundingBoxes(boxes, 100, 100)[0].palette).toEqual([
'#ff0000',
'#aabbcc'
])
})
it('fills defaults when metadata is missing or partial', () => {
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({

View File

@@ -202,6 +202,22 @@ function isBoundingBox(b: unknown): b is BoundingBox {
)
}
function normalizeHexColor(color: unknown): string | null {
if (typeof color !== 'string') return null
const hex = color.trim().toLowerCase()
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(hex)
if (short) {
return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`
}
return /^#([0-9a-f]{6}|[0-9a-f]{8})$/.test(hex) ? hex : null
}
function normalizePalette(palette: unknown): string[] {
return Array.isArray(palette)
? palette.map(normalizeHexColor).filter((c): c is string => c !== null)
: []
}
export function fromBoundingBoxes(
boxes: readonly BoundingBox[],
width: number,
@@ -219,9 +235,7 @@ export function fromBoundingBoxes(
type: meta.type === 'text' ? 'text' : 'obj',
text: typeof meta.text === 'string' ? meta.text : '',
desc: typeof meta.desc === 'string' ? meta.desc : '',
palette: Array.isArray(meta.palette)
? meta.palette.filter((c): c is string => typeof c === 'string')
: []
palette: normalizePalette(meta.palette)
}
})
}

View File

@@ -8,14 +8,32 @@ import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import { toNodeId } from '@/types/nodeId'
const { appState } = vi.hoisted(() => ({
appState: { node: null as unknown }
const { appState, outputState } = vi.hoisted(() => ({
appState: { node: null as unknown },
outputState: {
outputs: undefined as unknown,
nodeOutputs: null as { value: Record<string, unknown> } | null
}
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
vi.mock('@/stores/nodeOutputStore', async () => {
const { ref } = await import('vue')
const nodeOutputs = ref<Record<string, unknown>>({})
outputState.nodeOutputs = nodeOutputs
return {
useNodeOutputStore: () => ({
nodeOutputs,
nodePreviewImages: ref({}),
getNodeImageUrls: () => undefined,
getNodeOutputs: () => outputState.outputs
})
}
})
const ctx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
@@ -27,6 +45,9 @@ const ctx = {
save: () => {},
restore: () => {},
beginPath: () => {},
moveTo: () => {},
arc: () => {},
fill: () => {},
rect: () => {},
clip: () => {},
font: '',
@@ -58,17 +79,32 @@ function makeCanvas(): HTMLCanvasElement {
return el
}
function makeNode() {
interface MockNode {
widgets: { name: string; value: unknown }[]
findInputSlot: (name: string) => number
getInputNode: () => null
isInputConnected?: () => boolean
}
function makeNode(): MockNode {
return {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
{ name: 'height', value: 512 },
{ name: 'last_incoming', value: [] }
],
findInputSlot: () => -1,
getInputNode: () => null
}
}
const lastIncomingOf = (node: MockNode) =>
node.widgets.find((w) => w.name === 'last_incoming')!.value
const setLastIncomingOf = (node: MockNode, value: BoundingBox[]) => {
node.widgets.find((w) => w.name === 'last_incoming')!.value = value
}
const pe = (
clientX: number,
clientY: number,
@@ -96,6 +132,8 @@ interface Captured extends Api {
modelValue: Ref<BoundingBox[]>
}
const modelBoxes = (c: Captured) => c.modelValue.value
function setup(initial: BoundingBox[] = []) {
let captured: Captured | undefined
const Harness = defineComponent({
@@ -128,9 +166,19 @@ const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
...over
})
function makeConnectedNode(): MockNode {
return {
...makeNode(),
findInputSlot: (name: string) => (name === 'bboxes' ? 1 : -1),
isInputConnected: () => true
}
}
beforeEach(() => {
setActivePinia(createPinia())
appState.node = makeNode()
outputState.outputs = undefined
if (outputState.nodeOutputs) outputState.nodeOutputs.value = {}
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
void Promise.resolve().then(() => cb(0))
return 1
@@ -168,8 +216,8 @@ describe('useBoundingBoxes drawing', () => {
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBeGreaterThan(0)
})
it('discards a zero-size draw', async () => {
@@ -177,7 +225,7 @@ describe('useBoundingBoxes drawing', () => {
c.onPointerDown(pe(10, 10))
c.onDocPointerUp(pe(10, 10))
await flush()
expect(c.modelValue.value).toHaveLength(0)
expect(modelBoxes(c)).toHaveLength(0)
})
it('selects an existing region instead of drawing when clicking inside it', async () => {
@@ -185,7 +233,7 @@ describe('useBoundingBoxes drawing', () => {
c.onPointerDown(pe(30, 30))
c.onDocPointerUp(pe(30, 30))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(modelBoxes(c)).toHaveLength(1)
})
})
@@ -194,7 +242,7 @@ describe('useBoundingBoxes region editing', () => {
const c = setup([box()])
c.setActiveType('text')
await flush()
expect(c.modelValue.value[0].metadata.type).toBe('text')
expect(modelBoxes(c)[0].metadata.type).toBe('text')
})
it('deletes the active region on Delete', async () => {
@@ -205,14 +253,18 @@ describe('useBoundingBoxes region editing', () => {
stopPropagation: () => {}
} as unknown as KeyboardEvent)
await flush()
expect(c.modelValue.value).toHaveLength(0)
expect(modelBoxes(c)).toHaveLength(0)
})
it('clears all regions', async () => {
it('clears all regions and invalidates the applied upstream input', async () => {
const node = makeNode()
setLastIncomingOf(node, [box()])
appState.node = node
const c = setup([box(), box({ x: 0 })])
c.clearAll()
await flush()
expect(c.modelValue.value).toHaveLength(0)
expect(modelBoxes(c)).toHaveLength(0)
expect(lastIncomingOf(node)).toEqual([])
})
})
@@ -226,7 +278,7 @@ describe('useBoundingBoxes inline editor', () => {
c.inlineEditor.value!.value = 'a label'
c.commitInlineEditor()
await flush()
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
expect(modelBoxes(c)[0].metadata.desc).toBe('a label')
expect(c.inlineEditor.value).toBeNull()
})
@@ -239,6 +291,168 @@ describe('useBoundingBoxes inline editor', () => {
})
})
describe('useBoundingBoxes incoming bboxes input', () => {
it('adopts cached outputs on mount without overwriting existing edits', () => {
const node = makeConnectedNode()
appState.node = node
const incoming = [box({ x: 0, width: 100 })]
outputState.outputs = { input_bboxes: incoming }
const c = setup([box({ x: 200, width: 300 })])
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(300)
expect(lastIncomingOf(node)).toEqual(incoming)
})
it('does not re-apply an already applied output after a remount', async () => {
const node = makeConnectedNode()
const incoming = [box({ x: 0, width: 100 })]
setLastIncomingOf(node, incoming)
appState.node = node
outputState.outputs = { input_bboxes: incoming }
const c = setup([box({ x: 200, width: 300 })])
outputState.nodeOutputs!.value = { updated: true }
await flush()
expect(modelBoxes(c)[0].width).toBe(300)
})
it('ignores incoming output when the input is not connected', () => {
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
const c = setup([])
expect(modelBoxes(c)).toHaveLength(0)
})
it('repopulates from the next run after clearing the canvas', async () => {
appState.node = makeConnectedNode()
const c = setup([])
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
outputState.nodeOutputs!.value = { n: 1 }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
c.clearAll()
await flush()
expect(modelBoxes(c)).toHaveLength(0)
outputState.nodeOutputs!.value = { n: 2 }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(100)
})
it('does not apply output updates while the input is disconnected', async () => {
let connected = true
appState.node = {
...makeConnectedNode(),
isInputConnected: () => connected
}
const c = setup([])
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
outputState.nodeOutputs!.value = { n: 1 }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
c.clearAll()
await flush()
connected = false
outputState.nodeOutputs!.value = { n: 2 }
await flush()
expect(modelBoxes(c)).toHaveLength(0)
})
it('does not apply incoming boxes while the user is drawing', async () => {
appState.node = makeConnectedNode()
const c = setup([])
c.grid.value = false
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(50, 50))
outputState.outputs = {
input_bboxes: [box({ x: 0, width: 100, height: 100 })]
}
outputState.nodeOutputs!.value = { n: 1 }
await flush()
c.onDocPointerUp(pe(50, 50))
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(205)
})
it('applies incoming boxes when outputs stream in after mount', async () => {
const node = makeConnectedNode()
appState.node = node
const c = setup([])
expect(modelBoxes(c)).toHaveLength(0)
const incoming = [box({ x: 0, width: 100 })]
outputState.outputs = { input_bboxes: incoming }
outputState.nodeOutputs!.value = { updated: true }
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].width).toBe(100)
expect(lastIncomingOf(node)).toEqual(incoming)
})
it('re-seeds the canvas over user edits when the upstream value changes', async () => {
const node = makeConnectedNode()
setLastIncomingOf(node, [box({ x: 0, width: 100 })])
appState.node = node
const c = setup([box({ x: 200, width: 300 })])
const changed = [box({ x: 64, width: 128 })]
outputState.outputs = { input_bboxes: changed }
outputState.nodeOutputs!.value = { n: 1 }
await flush()
expect(modelBoxes(c)[0].width).toBe(128)
expect(lastIncomingOf(node)).toEqual(changed)
})
})
describe('useBoundingBoxes grid snapping', () => {
it('snaps a drawn box to the grid when grid is enabled (default)', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(modelBoxes(c)).toHaveLength(1)
expect(modelBoxes(c)[0].x).toBe(64)
expect(modelBoxes(c)[0].width).toBe(256)
})
it('does not snap when grid is disabled', async () => {
const c = setup()
c.grid.value = false
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(55, 55))
c.onDocPointerUp(pe(55, 55))
await flush()
expect(modelBoxes(c)[0].width).toBe(230)
})
it('keeps the anchored edge fixed when resizing a single edge', async () => {
const c = setup([box({ x: 51, y: 51, width: 256, height: 256 })])
c.onPointerDown(pe(60, 30))
c.onCanvasPointerMove(pe(80, 30))
c.onDocPointerUp(pe(80, 30))
await flush()
expect(modelBoxes(c)[0].x).toBe(51)
})
it('removes a box that a resize collapses to zero size', async () => {
const c = setup([box({ x: 64, y: 64, width: 128, height: 128 })])
c.onPointerDown(pe(37, 25))
c.onCanvasPointerMove(pe(14, 25))
c.onDocPointerUp(pe(14, 25))
await flush()
expect(modelBoxes(c)).toHaveLength(0)
})
})
describe('useBoundingBoxes hover cursor', () => {
it('switches to a pointer cursor over a tag', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])

View File

@@ -1,4 +1,5 @@
import { useElementSize } from '@vueuse/core'
import { cloneDeep, isEqual } from 'es-toolkit'
import { storeToRefs } from 'pinia'
import type { Ref, ShallowRef } from 'vue'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
@@ -15,6 +16,7 @@ import type {
Region
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
@@ -25,6 +27,10 @@ const HANDLE_PX = 8
const DIMENSION_STEP = 16
const BG_DIM = 0.75
const MAX_ELEMENT_COLORS = 5
const GRID_PX = 32
const MAX_GRID_CELLS = 64
const DOT_ALPHA = 0.18
const DOT_RADIUS = 1
interface InlineEditorState {
value: string
@@ -57,6 +63,7 @@ export function useBoundingBoxes(
const hoverTagIndex = ref<number | null>(null)
const bgImage = ref<HTMLImageElement | null>(null)
const inlineEditor = ref<InlineEditorState | null>(null)
const grid = ref(true)
const { width: containerWidth } = useElementSize(canvasContainer)
@@ -96,6 +103,89 @@ export function useBoundingBoxes(
return Math.max(0, Math.min(1, n))
}
function gridSpec() {
const axisFraction = (size: number) =>
Math.max(GRID_PX, Math.ceil(size / MAX_GRID_CELLS)) / size
return {
fx: axisFraction(widthValue.value),
fy: axisFraction(heightValue.value)
}
}
function snapFraction(value: number, step: number) {
return step > 0 ? clampToCanvas(Math.round(value / step) * step) : value
}
function snapRegion(region: Region, mode: HitMode): Region {
if (!grid.value) return region
const { fx, fy } = gridSpec()
if (mode === 'move') {
return {
...region,
x: Math.min(snapFraction(region.x, fx), 1 - region.w),
y: Math.min(snapFraction(region.y, fy), 1 - region.h)
}
}
const snapLeft =
mode === 'draw' ||
mode === 'resize-l' ||
mode === 'resize-tl' ||
mode === 'resize-bl'
const snapRight =
mode === 'draw' ||
mode === 'resize-r' ||
mode === 'resize-tr' ||
mode === 'resize-br'
const snapTop =
mode === 'draw' ||
mode === 'resize-t' ||
mode === 'resize-tl' ||
mode === 'resize-tr'
const snapBottom =
mode === 'draw' ||
mode === 'resize-b' ||
mode === 'resize-bl' ||
mode === 'resize-br'
const x1 = snapLeft ? snapFraction(region.x, fx) : region.x
const y1 = snapTop ? snapFraction(region.y, fy) : region.y
const x2 = snapRight
? snapFraction(region.x + region.w, fx)
: region.x + region.w
const y2 = snapBottom
? snapFraction(region.y + region.h, fy)
: region.y + region.h
return {
...region,
x: x1,
y: y1,
w: Math.max(0, x2 - x1),
h: Math.max(0, y2 - y1)
}
}
function drawDots(ctx: CanvasRenderingContext2D, W: number, H: number) {
const el = canvasEl.value
if (!el) return
const { fx, fy } = gridSpec()
if (fx <= 0 || fy <= 0) return
const cols = Math.round(1 / fx)
const rows = Math.round(1 / fy)
ctx.save()
ctx.globalAlpha = DOT_ALPHA
ctx.fillStyle = getComputedStyle(el).color
ctx.beginPath()
for (let i = 0; i <= cols; i++) {
const cx = Math.min(1, i * fx) * W
for (let j = 0; j <= rows; j++) {
const cy = Math.min(1, j * fy) * H
ctx.moveTo(cx + DOT_RADIUS, cy)
ctx.arc(cx, cy, DOT_RADIUS, 0, Math.PI * 2)
}
}
ctx.fill()
ctx.restore()
}
function logicalSize() {
const el = canvasEl.value
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
@@ -146,6 +236,8 @@ export function useBoundingBoxes(
ctx.fillRect(0, 0, W, H)
}
if (grid.value) drawDots(ctx, W, H)
const showActive = focused.value || isNodeSelected.value
const aIdx = showActive ? activeIndex.value : -1
const order = state.value.regions
@@ -366,7 +458,7 @@ export function useBoundingBoxes(
const dx = mN.x - dragStartNorm.value.x
const dy = mN.y - dragStartNorm.value.y
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
state.value.regions[activeIndex.value] = nb
state.value.regions[activeIndex.value] = snapRegion(nb, dragMode.value)
requestDraw()
}
@@ -375,7 +467,7 @@ export function useBoundingBoxes(
drawing.value = false
canvasEl.value?.releasePointerCapture?.(e.pointerId)
const b = state.value.regions[activeIndex.value]
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
if (b && (b.w < 0.005 || b.h < 0.005)) {
removeRegion(activeIndex.value)
}
syncState()
@@ -510,6 +602,7 @@ export function useBoundingBoxes(
function clearAll() {
state.value.regions = []
activeIndex.value = -1
setLastIncoming([])
syncState()
}
@@ -530,6 +623,23 @@ export function useBoundingBoxes(
watch(isNodeSelected, () => requestDraw())
watch([widthValue, heightValue], () => syncState())
watch(
litegraphNode,
(node) => {
const props = node?.properties as { bboxGrid?: unknown } | undefined
if (props && typeof props.bboxGrid === 'boolean')
grid.value = props.bboxGrid
},
{ immediate: true }
)
watch(grid, (enabled) => {
const props = litegraphNode.value?.properties as
| Record<string, unknown>
| undefined
if (props) props.bboxGrid = enabled
requestDraw()
})
const nodeOutputStore = useNodeOutputStore()
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
const node = litegraphNode.value
@@ -580,10 +690,63 @@ export function useBoundingBoxes(
}
img.src = url
}
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
function lastIncomingWidget() {
return litegraphNode.value?.widgets?.find((w) => w.name === 'last_incoming')
}
function lastIncomingValue(): BoundingBox[] {
const value = lastIncomingWidget()?.value
return Array.isArray(value) ? (value as BoundingBox[]) : []
}
function setLastIncoming(boxes: BoundingBox[]) {
const widget = lastIncomingWidget()
if (!widget) return
const next = cloneDeep(boxes)
widget.value = next
widget.callback?.(next)
}
function applyIncomingBoxes(apply = true) {
if (drawing.value) return
const node = litegraphNode.value
if (!node) return
const slot = node.findInputSlot('bboxes')
if (slot < 0 || !node.isInputConnected(slot)) return
const outputs = nodeOutputStore.getNodeOutputs(node) as
| NodeOutputWith<{ input_bboxes?: BoundingBox[] }>
| undefined
const incoming = outputs?.input_bboxes
if (!incoming?.length) return
const applied = lastIncomingValue()
if (isEqual(incoming, applied)) return
if (!apply) {
if (!applied.length && state.value.regions.length)
setLastIncoming(incoming)
return
}
state.value.regions = fromBoundingBoxes(
incoming,
widthValue.value,
heightValue.value
)
activeIndex.value = state.value.regions.length ? 0 : -1
setLastIncoming(incoming)
syncState()
}
watch(
() => nodeOutputStore.nodeOutputs,
() => {
updateBgImage()
applyIncomingBoxes()
},
{ deep: true }
)
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
updateBgImage()
applyIncomingBoxes(false)
void nextTick(() => requestDraw())
onBeforeUnmount(() => {
@@ -608,6 +771,7 @@ export function useBoundingBoxes(
commitInlineEditor,
setActiveType,
clearAll,
syncState
syncState,
grid
}
}

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
@@ -13,17 +13,12 @@ afterEach(() => {
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
usePaletteSwatchRow({ modelValue, container, picker })
)!
return { modelValue, container, picker, ...api }
const api = scope.run(() => usePaletteSwatchRow({ modelValue, container }))!
return { modelValue, container, ...api }
}
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
const { modelValue, addColor } = setup(['#000000'])
@@ -37,31 +32,17 @@ describe('usePaletteSwatchRow', () => {
expect(modelValue.value).toEqual(['#a', '#c'])
})
it('seeds the picker input with the clicked color before opening it', () => {
const { picker, openPicker } = setup(['#112233'])
const click = vi.spyOn(picker.value!, 'click')
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#112233')
expect(click).toHaveBeenCalled()
})
it('falls back to white when the slot is empty', () => {
const { picker, openPicker } = setup([''])
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#ffffff')
})
it('writes the picked color back to the open slot', () => {
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
openPicker(1, mouseEvent())
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
it('updates the color at an index', () => {
const { modelValue, updateAt } = setup(['#a', '#b'])
updateAt(1, '#123456')
expect(modelValue.value).toEqual(['#a', '#123456'])
})
it('ignores picker input when no slot is open', () => {
const { modelValue, onPickerInput } = setup(['#a'])
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a'])
it('ignores an update that does not change the color', () => {
const { modelValue, updateAt } = setup(['#a'])
const before = modelValue.value
updateAt(0, '#a')
expect(modelValue.value).toBe(before)
})
it('reorders via drag when the pointer crosses another swatch', () => {

View File

@@ -5,30 +5,16 @@ import { ref } from 'vue'
interface UsePaletteSwatchRowOptions {
modelValue: Ref<string[]>
container: Readonly<ShallowRef<HTMLDivElement | null>>
picker: Readonly<ShallowRef<HTMLInputElement | null>>
}
export function usePaletteSwatchRow({
modelValue,
container,
picker
container
}: UsePaletteSwatchRowOptions) {
const pickerIndex = ref<number | null>(null)
function openPicker(i: number, e: MouseEvent) {
e.stopPropagation()
pickerIndex.value = i
const el = picker.value
if (!el) return
el.value = modelValue.value[i] || '#ffffff'
el.click()
}
function onPickerInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (pickerIndex.value === null) return
function updateAt(i: number, value: string) {
if (modelValue.value[i] === value) return
const next = modelValue.value.slice()
next[pickerIndex.value] = v
next[i] = value
modelValue.value = next
}
@@ -105,8 +91,7 @@ export function usePaletteSwatchRow({
})
return {
openPicker,
onPickerInput,
updateAt,
remove,
addColor,
onPointerDown

View File

@@ -77,6 +77,14 @@ vi.mock('pinia', async (importOriginal) => {
}
})
const { settingGetMock } = vi.hoisted(() => ({
settingGetMock: vi.fn()
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: settingGetMock })
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn()
}))
@@ -95,6 +103,9 @@ describe('useLoad3d', () => {
vi.clearAllMocks()
nodeToLoad3dMap.clear()
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)
settingGetMock.mockImplementation((key: string) =>
key === 'Comfy.Load3D.BackgroundColor' ? '282828' : undefined
)
mockNode = createMockLGraphNode({
properties: {
@@ -356,6 +367,20 @@ describe('useLoad3d', () => {
expect(composable.isPreview.value).toBe(true)
})
it('should set preview mode for save-viewer nodes despite width/height widgets', async () => {
Object.defineProperty(mockNode, 'constructor', {
value: { comfyClass: 'Save3DAdvanced' },
configurable: true
})
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
expect(composable.isPreview.value).toBe(true)
})
it('should handle initialization errors', async () => {
vi.mocked(createLoad3d).mockImplementationOnce(() => {
throw new Error('Load3d creation failed')
@@ -383,7 +408,37 @@ describe('useLoad3d', () => {
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
const composable = useLoad3d(nodeRef)
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
expect(composable.sceneConfig.value.backgroundColor).toBe('#282828')
})
it('defaults background color from the Comfy.Load3D.BackgroundColor setting', () => {
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
vi.mocked(useCanvasStore).mockReturnValue(
reactive({ appScalePercentage: 100 }) as unknown as ReturnType<
typeof useCanvasStore
>
)
settingGetMock.mockImplementation((key: string) =>
key === 'Comfy.Load3D.BackgroundColor' ? '123456' : undefined
)
const composable = useLoad3d(mockNode)
expect(composable.sceneConfig.value.backgroundColor).toBe('#123456')
})
it('attaches event listeners before running queued ready callbacks', async () => {
const composable = useLoad3d(mockNode)
let listenersAttachedWhenCallbackRan = false
composable.waitForLoad3d(() => {
listenersAttachedWhenCallbackRan =
vi.mocked(mockLoad3d.addEventListener!).mock.calls.length > 0
})
await composable.initializeLoad3d(document.createElement('div'))
expect(listenersAttachedWhenCallbackRan).toBe(true)
})
it('passes getZoomScale callback to createLoad3d', async () => {

View File

@@ -8,6 +8,7 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
import {
isAssetPreviewSupported,
persistThumbnail
@@ -118,7 +119,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const sceneConfig = ref<SceneConfig>({
showGrid: true,
backgroundColor: '#000000',
backgroundColor: getActivePinia()
? '#' + useSettingStore().get('Comfy.Load3D.BackgroundColor')
: '#282828',
backgroundImage: '',
backgroundRenderMode: 'tiled'
})
@@ -192,6 +195,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (
isLoad3dResultViewerNode(node.constructor.comfyClass ?? '') ||
node.constructor.comfyClass?.startsWith('Preview') ||
!(widthWidget && heightWidget)
) {
@@ -248,6 +252,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
nodeToLoad3dMap.set(node, load3d)
handleEvents('add')
const callbacks = pendingCallbacks.get(node)
if (callbacks && load3d) {
@@ -263,8 +269,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d) invokeReadyCallback(callback, load3d)
})
}
handleEvents('add')
} catch (error) {
console.error('Error initializing Load3d:', error)
useToastStore().addAlert(

View File

@@ -4,7 +4,7 @@ import QuickLRU from '@alloc/quick-lru'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
import type {
AnimationItem,
BackgroundRenderModeType,
@@ -371,7 +371,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
| LightConfig
| undefined
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
isPreview.value = isLoad3dResultViewerNode(node.type ?? '')
if (sceneConfig) {
backgroundColor.value =

View File

@@ -0,0 +1,48 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import { useLitegraphService } from '@/services/litegraphService'
type MockInputs = ('INT' | 'STRING' | 'IMAGE' | MockInputs)[][]
export function addDynamicCombo(node: LGraphNode, rootMockInputs: MockInputs) {
const namePrefix = `${node.widgets?.length ?? 0}`
function getSpec(
mockInputs: MockInputs,
depth: number = 0
): { key: string; inputs: object }[] {
return mockInputs.map((mockGroup, groupIndex) => {
const inputSpec = mockGroup.map((mockInput, inputIndex) => [
`${namePrefix}.${depth}.${inputIndex}`,
Array.isArray(mockInput)
? [
'COMFY_DYNAMICCOMBO_V3',
{ options: getSpec(mockInput, depth + 1) }
]
: [mockInput, { tooltip: `${groupIndex}` }]
])
return {
key: `${groupIndex}`,
inputs: { required: Object.fromEntries(inputSpec) }
}
})
}
const inputSpec: Required<InputSpec> = [
'COMFY_DYNAMICCOMBO_V3',
{ options: getSpec(rootMockInputs) }
]
useLitegraphService().addNodeInput(
node,
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
)
}
export function addAutogrow(node: LGraphNode, template: unknown) {
useLitegraphService().addNodeInput(
node,
transformInputSpecV1ToV2(['COMFY_AUTOGROW_V3', { template }], {
name: `${node.inputs.length}`,
isOptional: false
})
)
}

View File

@@ -1,14 +1,14 @@
import { setActivePinia } from 'pinia'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, test, vi } from 'vitest'
import {
addAutogrow,
addDynamicCombo
} from '@/core/graph/widgets/__fixtures__/dynamicInputHelpers'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import { useLitegraphService } from '@/services/litegraphService'
import type { HasInitialMinSize } from '@/services/litegraphService'
setActivePinia(createTestingPinia())
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
type TestAutogrowNode = LGraphNode & {
comfyDynamic: { autogrow: Record<string, unknown> }
}
@@ -19,43 +19,6 @@ function nextTick() {
return new Promise<void>((r) => requestAnimationFrame(() => r()))
}
function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
const namePrefix = `${node.widgets?.length ?? 0}`
function getSpec(
inputs: DynamicInputs,
depth: number = 0
): { key: string; inputs: object }[] {
return inputs.map((group, groupIndex) => {
const inputs = group.map((input, inputIndex) => [
`${namePrefix}.${depth}.${inputIndex}`,
Array.isArray(input)
? ['COMFY_DYNAMICCOMBO_V3', { options: getSpec(input, depth + 1) }]
: [input, { tooltip: `${groupIndex}` }]
])
return {
key: `${groupIndex}`,
inputs: { required: Object.fromEntries(inputs) }
}
})
}
const inputSpec: Required<InputSpec> = [
'COMFY_DYNAMICCOMBO_V3',
{ options: getSpec(inputs) }
]
addNodeInput(
node,
transformInputSpecV1ToV2(inputSpec, { name: namePrefix, isOptional: false })
)
}
function addAutogrow(node: LGraphNode, template: unknown) {
addNodeInput(
node,
transformInputSpecV1ToV2(['COMFY_AUTOGROW_V3', { template }], {
name: `${node.inputs.length}`,
isOptional: false
})
)
}
function connectInput(node: LGraphNode, inputIndex: number, graph: LGraph) {
const node2 = testNode()
node2.addOutput('out', '*')
@@ -63,12 +26,8 @@ function connectInput(node: LGraphNode, inputIndex: number, graph: LGraph) {
node2.connect(0, node, inputIndex)
}
function testNode() {
const node: LGraphNode & Partial<HasInitialMinSize> = new LGraphNode('test')
const node = new LGraphNode('test')
node.widgets = []
node._initialMinSize = { width: 1, height: 1 }
node.constructor.nodeData = {
name: 'testnode'
} as typeof node.constructor.nodeData
return node as LGraphNode & Required<Pick<LGraphNode, 'widgets'>>
}

View File

@@ -32,7 +32,8 @@ function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
const widgets: MockWidget[] = [
{ name: 'width', hidden: false, options: {} },
{ name: 'height', hidden: false, options: {} },
{ name: 'other', hidden: false, options: {} }
{ name: 'other', hidden: false, options: {} },
{ name: 'last_incoming', hidden: false, options: {} }
]
return {
constructor: { comfyClass },
@@ -73,6 +74,15 @@ describe('Comfy.CreateBoundingBoxes extension', () => {
expect(node.widgets[0].options.hidden).toBe(false)
})
it('always hides the internal last_incoming widget', () => {
for (const connected of [true, false]) {
const node = makeNode(connected)
state.extension!.nodeCreated(node)
expect(node.widgets[3].hidden).toBe(true)
expect(node.widgets[3].options.hidden).toBe(true)
}
})
it('writes visibility through the widget value store when present', () => {
state.widgetState = { options: {} }
const node = makeNode(true)

View File

@@ -3,6 +3,7 @@ import { useExtensionService } from '@/services/extensionService'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
const DIMENSION_WIDGETS = new Set(['width', 'height'])
const INTERNAL_WIDGETS = new Set(['last_incoming'])
useExtensionService().registerExtension({
name: 'Comfy.CreateBoundingBoxes',
@@ -15,20 +16,30 @@ useExtensionService().registerExtension({
const widgetValueStore = useWidgetValueStore()
const setWidgetHidden = (
widget: NonNullable<typeof node.widgets>[number],
hidden: boolean
) => {
widget.hidden = hidden
const state = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: undefined
if (state?.options) state.options.hidden = hidden
else widget.options.hidden = hidden
}
const syncDimensionVisibility = () => {
const slot = node.findInputSlot('background')
const hidden = slot >= 0 && node.isInputConnected(slot)
for (const widget of node.widgets ?? []) {
if (!DIMENSION_WIDGETS.has(widget.name)) continue
widget.hidden = hidden
const state = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: undefined
if (state?.options) state.options.hidden = hidden
else widget.options.hidden = hidden
if (DIMENSION_WIDGETS.has(widget.name)) setWidgetHidden(widget, hidden)
}
}
for (const widget of node.widgets ?? []) {
if (INTERNAL_WIDGETS.has(widget.name)) setWidgetHidden(widget, true)
}
syncDimensionVisibility()
node.onConnectionsChange = useChainCallback(
node.onConnectionsChange,

View File

@@ -143,14 +143,23 @@ async function loadExtensionsFresh(): Promise<{
load3DExt: ExtCreated
preview3DExt: ExtCreated
preview3DAdvancedExt: ExtCreated
save3DAdvancedExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/load3d')
const extByName = (name: string): ExtCreated => {
const call = registerExtensionMock.mock.calls.find(
(c) => (c[0] as ExtCreated).name === name
)
if (!call) throw new Error(`Extension ${name} was not registered`)
return call[0] as ExtCreated
}
return {
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
load3DExt: extByName('Comfy.Load3D'),
preview3DExt: extByName('Comfy.Preview3D'),
preview3DAdvancedExt: extByName('Comfy.Preview3DAdvanced'),
save3DAdvancedExt: extByName('Comfy.Save3DAdvanced')
}
}
@@ -264,14 +273,15 @@ function setupBaseMocks() {
describe('load3d module registration', () => {
beforeEach(setupBaseMocks)
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
it('registers Comfy.Load3D, Comfy.Preview3D, Comfy.Preview3DAdvanced, and Comfy.Save3DAdvanced extensions on import', async () => {
const { load3DExt, preview3DExt, preview3DAdvancedExt, save3DAdvancedExt } =
await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
expect(load3DExt.name).toBe('Comfy.Load3D')
expect(preview3DExt.name).toBe('Comfy.Preview3D')
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
expect(save3DAdvancedExt.name).toBe('Comfy.Save3DAdvanced')
})
})
@@ -711,6 +721,39 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
})
})
describe('Comfy.Save3DAdvanced.onNodeOutputsUpdated', () => {
beforeEach(setupBaseMocks)
it('restores the saved model from the output folder when opened from history', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
getNodeByLocatorIdMock.mockReturnValue(node)
save3DAdvancedExt.onNodeOutputsUpdated!({
'7': { result: ['3d\\ComfyUI_00001.glb'] }
} as never)
expect(node.properties['Last Time Model File']).toBe('3d/ComfyUI_00001.glb')
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00001.glb',
expect.objectContaining({ silentOnNotFound: true })
)
})
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
getNodeByLocatorIdMock.mockReturnValue(node)
save3DAdvancedExt.onNodeOutputsUpdated!({
'7': { result: ['mesh.glb'] }
} as never)
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
})
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
beforeEach(setupBaseMocks)
@@ -1032,6 +1075,50 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
})
})
describe('Comfy.Save3DAdvanced.nodeCreated', () => {
beforeEach(setupBaseMocks)
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
await save3DAdvancedExt.nodeCreated(node)
expect(waitForLoad3dMock).not.toHaveBeenCalled()
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
})
it('restores persisted models from the output folder, not temp', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({
comfyClass: 'Save3DAdvanced',
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.glb' }
})
await save3DAdvancedExt.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00001_.glb',
{ silentOnNotFound: true }
)
})
it('onExecuted loads the saved file from the output folder', async () => {
const { save3DAdvancedExt } = await loadExtensionsFresh()
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
await save3DAdvancedExt.nodeCreated(node)
node.onExecuted!({ result: ['3d/ComfyUI_00002_.glb'] })
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00002_.glb',
{ silentOnNotFound: true }
)
})
})
describe('Comfy.Load3D scene widget serializeValue caching', () => {
beforeEach(setupBaseMocks)

View File

@@ -15,8 +15,10 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
import type {
CameraConfig,
CameraState,
LoadFolder,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type Load3d from '@/extensions/core/load3d/Load3d'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import {
LOAD3D_NONE_MODEL,
@@ -48,6 +50,7 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
import type { ComfyExtension } from '@/types/comfy'
import { isLoad3dNode } from '@/utils/litegraphUtil'
const inputSpecLoad3D: CustomInputSpec = {
@@ -287,8 +290,11 @@ useExtensionService().registerExtension({
getCustomWidgets() {
const VIEWPORT_STATE_NODES = new Set([
'Preview3DAdvanced',
'Save3DAdvanced',
'PreviewGaussianSplat',
'PreviewPointCloud'
'PreviewPointCloud',
'SaveGaussianSplat',
'SavePointCloud'
])
return {
LOAD_3D(node) {
@@ -679,155 +685,215 @@ useExtensionService().registerExtension({
}
})
useExtensionService().registerExtension({
name: 'Comfy.Preview3DAdvanced',
function applyPreview3DAdvancedResult(
node: LGraphNode,
load3d: Load3d,
result: NonNullable<Preview3DAdvancedOutput['result']>,
loadFolder: LoadFolder,
comfyClass: string
): void {
const filePath = result[0]
if (!filePath) return
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(loadFolder, normalizedPath, {
silentOnNotFound: true
})
if (load3d.isSplatModel()) return []
const cameraState = result[1]
const modelTransform = result[2]?.[0]
if (!cameraState && !modelTransform) return
return createExportMenuItems(load3d)
},
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
if (cameraState) load3d.setCameraState(cameraState)
if (modelTransform) load3d.applyModelTransform(modelTransform)
})
.catch((error) => {
console.error(
`Failed to apply input camera_info / model_3d_info from ${comfyClass}:`,
error
)
})
}
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
function createPreview3DAdvancedExtension(
comfyClass: string,
extensionName: string,
loadFolder: LoadFolder
): ComfyExtension {
return {
name: extensionName,
const [oldWidth, oldHeight] = node.size
onNodeOutputsUpdated(
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
) {
for (const [locatorId, output] of Object.entries(nodeOutputs)) {
const result = (output as Preview3DAdvancedOutput).result
if (!result?.[0]) continue
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
const node = getNodeByLocatorId(app.rootGraph, locatorId)
if (!node || node.constructor.comfyClass !== comfyClass) continue
await nextTick()
const onExecuted = node.onExecuted
useLoad3d(node).onLoad3dReady((load3d) => {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
silentOnNotFound: true
})
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
if (!cameraState) return
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
load3d.setCameraState(cameraState)
load3d.forceRender()
})
.catch((error) => {
console.error(
'Failed to restore camera state for Preview3DAdvanced:',
error
useLoad3d(node).waitForLoad3d((load3d) => {
applyPreview3DAdvancedResult(
node,
load3d,
result,
loadFolder,
comfyClass
)
})
})
useLoad3d(node).waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
if (!sceneWidget) return
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && heightWidget) {
load3d.setTargetSize(
widthWidget.value as number,
heightWidget.value as number
)
widthWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
}
}
},
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== comfyClass) return []
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
| CameraConfig
| undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
if (load3d.isSplatModel()) return []
return {
image: '',
mask: '',
normal: '',
camera_info: cameraConfig.state || null,
recording: '',
model_3d_info
}
}
return createExportMenuItems(load3d)
},
node.onExecuted = function (output: Preview3DAdvancedOutput) {
onExecuted?.call(this, output)
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== comfyClass) return
const result = output.result
const filePath = result?.[0]
const [oldWidth, oldHeight] = node.size
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
await nextTick()
const currentLoad3d = resolveLoad3d()
const config = new Load3DConfiguration(currentLoad3d, node.properties)
config.configureForSaveMesh('temp', normalizedPath, {
const onExecuted = node.onExecuted
const { onLoad3dReady, waitForLoad3d } = useLoad3d(node)
onLoad3dReady((load3d) => {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
silentOnNotFound: true
})
const cameraState = result?.[1]
const modelTransform = result?.[2]?.[0]
if (cameraState || modelTransform) {
const targetGeneration = currentLoad3d.currentLoadGeneration
void currentLoad3d
.whenLoadIdle()
.then(() => {
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
return
if (cameraState) currentLoad3d.setCameraState(cameraState)
if (modelTransform)
currentLoad3d.applyModelTransform(modelTransform)
})
.catch((error) => {
console.error(
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
error
)
})
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
if (!cameraState) return
const targetGeneration = load3d.currentLoadGeneration
void load3d
.whenLoadIdle()
.then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
load3d.setCameraState(cameraState)
load3d.forceRender()
})
.catch((error) => {
console.error(
`Failed to restore camera state for ${comfyClass}:`,
error
)
})
})
waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find(
(w) => w.name === 'viewport_state'
)
if (!sceneWidget) return
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')
if (widthWidget && heightWidget) {
load3d.setTargetSize(
widthWidget.value as number,
heightWidget.value as number
)
widthWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
}
}
}
})
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
image: '',
mask: '',
normal: '',
camera_info: cameraConfig.state || null,
recording: '',
model_3d_info
}
}
node.onExecuted = function (output: Preview3DAdvancedOutput) {
onExecuted?.call(this, output)
const result = output.result
if (!result?.[0]) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
return
}
applyPreview3DAdvancedResult(
node,
resolveLoad3d(),
result,
loadFolder,
comfyClass
)
}
})
}
}
})
}
useExtensionService().registerExtension(
createPreview3DAdvancedExtension(
'Preview3DAdvanced',
'Comfy.Preview3DAdvanced',
'temp'
)
)
useExtensionService().registerExtension(
createPreview3DAdvancedExtension(
'Save3DAdvanced',
'Comfy.Save3DAdvanced',
'output'
)
)

View File

@@ -14,6 +14,7 @@ export type MaterialMode =
export type UpDirection = 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
export type CameraType = 'perspective' | 'orthographic'
export type BackgroundRenderModeType = 'tiled' | 'panorama'
export type LoadFolder = 'temp' | 'output'
interface CameraQuaternion {
x: number

View File

@@ -3,21 +3,24 @@
* Adding a new node type that uses the viewer = one line change here.
*/
const LOAD3D_PREVIEW_NODES = new Set([
const LOAD3D_RESULT_VIEWER_NODES = new Set([
'Preview3D',
'PreviewGaussianSplat',
'PreviewPointCloud'
'PreviewPointCloud',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud'
])
const LOAD3D_ALL_NODES = new Set([
...LOAD3D_PREVIEW_NODES,
...LOAD3D_RESULT_VIEWER_NODES,
'Load3D',
'Load3DAdvanced',
'SaveGLB'
])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)
export const isLoad3dResultViewerNode = (nodeType: string): boolean =>
LOAD3D_RESULT_VIEWER_NODES.has(nodeType)
export const isLoad3dNode = (nodeType: string): boolean =>
LOAD3D_ALL_NODES.has(nodeType)

View File

@@ -90,7 +90,10 @@ describe('load3dLazy', () => {
'Preview3D',
'PreviewGaussianSplat',
'PreviewPointCloud',
'SaveGLB'
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud'
])(
'recognizes %s as a 3D node type and triggers the lazy-load path',
async (nodeType) => {

View File

@@ -76,14 +76,24 @@ type ExtCreated = ComfyExtension & {
async function loadExtensionsFresh(): Promise<{
splatExt: ExtCreated
pointCloudExt: ExtCreated
saveSplatExt: ExtCreated
savePointCloudExt: ExtCreated
}> {
vi.resetModules()
registerExtensionMock.mockClear()
await import('@/extensions/core/load3dPreviewExtensions')
const [splatCall, pointCloudCall] = registerExtensionMock.mock.calls
const extByName = (name: string): ExtCreated => {
const call = registerExtensionMock.mock.calls.find(
(c) => (c[0] as ExtCreated).name === name
)
if (!call) throw new Error(`Extension ${name} was not registered`)
return call[0] as ExtCreated
}
return {
splatExt: splatCall[0] as ExtCreated,
pointCloudExt: pointCloudCall[0] as ExtCreated
splatExt: extByName('Comfy.PreviewGaussianSplat'),
pointCloudExt: extByName('Comfy.PreviewPointCloud'),
saveSplatExt: extByName('Comfy.SaveGaussianSplat'),
savePointCloudExt: extByName('Comfy.SavePointCloud')
}
}
@@ -92,6 +102,7 @@ interface FakeLoad3d {
isSplatModel: ReturnType<typeof vi.fn>
forceRender: ReturnType<typeof vi.fn>
setCameraState: ReturnType<typeof vi.fn>
applyModelTransform: ReturnType<typeof vi.fn>
setTargetSize: ReturnType<typeof vi.fn>
getCurrentCameraType: ReturnType<typeof vi.fn>
getCameraState: ReturnType<typeof vi.fn>
@@ -106,6 +117,7 @@ function makeLoad3dMock(): FakeLoad3d {
isSplatModel: vi.fn(() => false),
forceRender: vi.fn(),
setCameraState: vi.fn(),
applyModelTransform: vi.fn(),
setTargetSize: vi.fn(),
getCurrentCameraType: vi.fn(() => 'perspective'),
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
@@ -151,12 +163,59 @@ function setupBaseMocks() {
describe('load3dPreviewExtensions module registration', () => {
beforeEach(setupBaseMocks)
it('registers both preview extensions on import', async () => {
const { splatExt, pointCloudExt } = await loadExtensionsFresh()
it('registers preview and save extensions on import', async () => {
const { splatExt, pointCloudExt, saveSplatExt, savePointCloudExt } =
await loadExtensionsFresh()
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
expect(splatExt.name).toBe('Comfy.PreviewGaussianSplat')
expect(pointCloudExt.name).toBe('Comfy.PreviewPointCloud')
expect(saveSplatExt.name).toBe('Comfy.SaveGaussianSplat')
expect(savePointCloudExt.name).toBe('Comfy.SavePointCloud')
})
it('save extensions load the saved file from the output folder, not temp', async () => {
const { saveSplatExt, savePointCloudExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const splatNode = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
await saveSplatExt.nodeCreated(splatNode)
splatNode.onExecuted!({ result: ['3d/ComfyUI_00001_.ply'] })
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'3d/ComfyUI_00001_.ply',
expect.objectContaining({ silentOnNotFound: true })
)
const pcNode = makePreviewNode({ comfyClass: 'SavePointCloud' })
await savePointCloudExt.nodeCreated(pcNode)
pcNode.onExecuted!({ result: ['3d/ComfyUI_00002_.ply'] })
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'3d/ComfyUI_00002_.ply',
expect.objectContaining({ silentOnNotFound: true })
)
})
it('restores persisted models from the output folder on nodeCreated, not temp', async () => {
const { saveSplatExt } = await loadExtensionsFresh()
const node = makePreviewNode({
comfyClass: 'SaveGaussianSplat',
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.ply' }
})
await saveSplatExt.nodeCreated(node)
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
'output',
'3d/ComfyUI_00001_.ply',
expect.objectContaining({ silentOnNotFound: true })
)
})
})
@@ -214,6 +273,44 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
expect(cameraConfig?.state).toEqual(cameraState)
})
it('applies onExecuted results to the remounted instance, not the disposed closure', async () => {
const { splatExt } = await loadExtensionsFresh()
const original = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(original)
)
const node = makePreviewNode()
await splatExt.nodeCreated(node)
const remounted = makeLoad3dMock()
nodeToLoad3dMapMock.set(node, remounted)
node.onExecuted!({
result: ['scene.ply', { position: { x: 1, y: 2, z: 3 } }]
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(remounted.forceRender).toHaveBeenCalled()
expect(original.forceRender).not.toHaveBeenCalled()
})
it('re-applies the model transform from result[2] on execute', async () => {
const { saveSplatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
cb(load3d)
)
const node = makePreviewNode({ comfyClass: 'SaveGaussianSplat' })
const transform = { position: { x: 1, y: 2, z: 3 } }
await saveSplatExt.nodeCreated(node)
node.onExecuted!({ result: ['scene.ply', undefined, [transform]] })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(load3d.applyModelTransform).toHaveBeenCalledWith(transform)
})
it('syncs width/height widgets to load3d.setTargetSize and registers callbacks', async () => {
const { splatExt } = await loadExtensionsFresh()
const load3d = makeLoad3dMock()

View File

@@ -5,6 +5,7 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
import type {
CameraConfig,
CameraState,
LoadFolder,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type Load3d from '@/extensions/core/load3d/Load3d'
@@ -29,7 +30,9 @@ function applyResultToLoad3d(
node: LGraphNode,
load3d: Load3d,
filePath: string,
cameraState: CameraState | undefined
cameraState: CameraState | undefined,
modelTransform: Model3DInfo[number] | undefined,
loadFolder: LoadFolder
): void {
const normalizedPath = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = normalizedPath
@@ -46,7 +49,7 @@ function applyResultToLoad3d(
}
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', normalizedPath, {
config.configureForSaveMesh(loadFolder, normalizedPath, {
silentOnNotFound: true
})
@@ -54,13 +57,15 @@ function applyResultToLoad3d(
void load3d.whenLoadIdle().then(() => {
if (load3d.currentLoadGeneration !== targetGeneration) return
if (cameraState) load3d.setCameraState(cameraState)
if (modelTransform) load3d.applyModelTransform(modelTransform)
load3d.forceRender()
})
}
function createPreview3DExtension(
comfyClass: string,
extensionName: string
extensionName: string,
loadFolder: LoadFolder
): ComfyExtension {
const applyPreviewOutput = (
node: LGraphNode,
@@ -68,10 +73,18 @@ function createPreview3DExtension(
): void => {
const filePath = result[0]
const cameraState = result[1]
const modelTransform = result[2]?.[0]
if (!filePath) return
useLoad3d(node).waitForLoad3d((load3d) => {
applyResultToLoad3d(node, load3d, filePath, cameraState)
applyResultToLoad3d(
node,
load3d,
filePath,
cameraState,
modelTransform,
loadFolder
)
})
}
@@ -119,7 +132,7 @@ function createPreview3DExtension(
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
silentOnNotFound: true
})
@@ -136,6 +149,8 @@ function createPreview3DExtension(
})
waitForLoad3d((load3d) => {
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
const sceneWidget = node.widgets?.find(
(w) => w.name === 'viewport_state'
)
@@ -148,10 +163,10 @@ function createPreview3DExtension(
heightWidget.value as number
)
widthWidget.callback = (value: number) => {
load3d.setTargetSize(value, heightWidget.value as number)
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
}
heightWidget.callback = (value: number) => {
load3d.setTargetSize(widthWidget.value as number, value)
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
}
}
@@ -199,7 +214,14 @@ function createPreview3DExtension(
return
}
applyResultToLoad3d(node, load3d, filePath, result?.[1])
applyResultToLoad3d(
node,
resolveLoad3d(),
filePath,
result?.[1],
result?.[2]?.[0],
loadFolder
)
}
})
}
@@ -207,8 +229,26 @@ function createPreview3DExtension(
}
useExtensionService().registerExtension(
createPreview3DExtension('PreviewGaussianSplat', 'Comfy.PreviewGaussianSplat')
createPreview3DExtension(
'PreviewGaussianSplat',
'Comfy.PreviewGaussianSplat',
'temp'
)
)
useExtensionService().registerExtension(
createPreview3DExtension('PreviewPointCloud', 'Comfy.PreviewPointCloud')
createPreview3DExtension(
'PreviewPointCloud',
'Comfy.PreviewPointCloud',
'temp'
)
)
useExtensionService().registerExtension(
createPreview3DExtension(
'SaveGaussianSplat',
'Comfy.SaveGaussianSplat',
'output'
)
)
useExtensionService().registerExtension(
createPreview3DExtension('SavePointCloud', 'Comfy.SavePointCloud', 'output')
)

View File

@@ -81,6 +81,9 @@ describe('Comfy.SaveImageExtraOutput', () => {
'SaveAudioOpus',
'SaveAudioAdvanced',
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud',
'SaveAnimatedPNG',
'CLIPSave',
'VAESave',

View File

@@ -16,6 +16,9 @@ const saveNodeTypes = new Set([
'SaveAudioOpus',
'SaveAudioAdvanced',
'SaveGLB',
'Save3DAdvanced',
'SaveGaussianSplat',
'SavePointCloud',
'SaveAnimatedPNG',
'CLIPSave',
'VAESave',

View File

@@ -925,7 +925,26 @@ export class LGraphNode
)
}
if (info.widgets_values) {
const getNamedValues = () => {
if (info.widgets_values_named) return info.widgets_values_named
const map = this.constructor.nodeData?.fallbackWidgetsValuesNames
if (!info.widgets_values || !map) return
return Object.fromEntries(
info.widgets_values.flatMap((v, i) => (map[i] ? [[map[i], v]] : []))
)
}
const namedValues = getNamedValues()
if (namedValues && LiteGraph.namedValuesRestore) {
for (const widget of this.widgets) {
if (widget.serialize === false || !(widget.name in namedValues))
continue
widget.value = namedValues[widget.name]
}
} else if (info.widgets_values) {
let i = 0
for (const widget of this.widgets ?? []) {
if (widget.serialize === false) continue
@@ -980,16 +999,19 @@ export class LGraphNode
if (this.properties) o.properties = LiteGraph.cloneObject(this.properties)
const { widgets } = this
if (widgets && this.serialize_widgets) {
if (widgets?.length && this.serialize_widgets) {
o.widgets_values = []
o.widgets_values_named = {}
for (const [i, widget] of widgets.entries()) {
if (widget.serialize === false) continue
const val = widget?.value
const val = widget.value
// Ensure object values are plain (not reactive proxies) for structuredClone compatibility.
o.widgets_values[i] =
const serialisedVal =
val != null && typeof val === 'object'
? JSON.parse(JSON.stringify(val))
: (val ?? null)
o.widgets_values[i] = serialisedVal
o.widgets_values_named[widget.name] = serialisedVal
}
}

View File

@@ -2,8 +2,10 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { addDynamicCombo } from '@/core/graph/widgets/__fixtures__/dynamicInputHelpers'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { sortWidgetValuesByInputOrder } from '@/workbench/utils/nodeDefOrderingUtil'
describe('LGraphNode widget ordering', () => {
@@ -15,6 +17,9 @@ describe('LGraphNode widget ordering', () => {
})
describe('configure with widgets_values', () => {
beforeEach(() => {
LiteGraph.namedValuesRestore = false
})
it('should apply widget values in correct order when widgets order matches input_order', () => {
// Create node with widgets
node.addWidget('number', 'steps', 20, null, {})
@@ -98,6 +103,120 @@ describe('LGraphNode widget ordering', () => {
expect(node.widgets![2].value).toBe(12345) // seed
})
})
describe('configure with widgets_values_named', () => {
beforeEach(() => {
LiteGraph.namedValuesRestore = true
})
it('should apply widget values from widgets_values_named', () => {
// Create node with widgets
node.addWidget('number', 'steps', 20, null, {})
node.addWidget('number', 'seed', 0, null, {})
node.addWidget('text', 'prompt', '', null, {})
// Configure with widget values
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [30, 12345, 'test prompt'],
widgets_values_named: { steps: 15, prompt: 'prompt', seed: 54321 }
}
node.configure(info)
// Check widget values are applied correctly
expect(node.widgets![0].value).toBe(15) // steps
expect(node.widgets![1].value).toBe(54321) // seed
expect(node.widgets![2].value).toBe('prompt') // prompt
})
it('should skip widgets with serialize: false', () => {
node.addWidget('number', 'steps', 20, null, {})
node.addWidget('button', 'action', 'Click', null, {})
node.widgets![1].serialize = false // button should not serialize
node.addWidget('number', 'seed', 0, null, {})
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [30, 12345], // Only serializable widgets
widgets_values_named: { steps: 30, seed: 12345 }
}
node.configure(info)
expect(node.widgets![0].value).toBe(30) // steps
expect(node.widgets![1].value).toBe('Click') // button unchanged
expect(node.widgets![2].value).toBe(12345) // seed
})
it('should restore widgets which are dynamically added', () => {
addDynamicCombo(node, [['INT'], ['INT', 'STRING']])
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values_named: { '0': 1, '0.0.0.0': 5, '0.0.0.1': 'test' }
}
node.configure(info)
expect(node.widgets![0].value).toBe(1)
expect(node.widgets![1].value).toBe(5)
expect(node.widgets![2].value).toBe('test')
})
it('should support restoration even when order has changed', () => {
node.addWidget('number', 'steps', 20, null, {})
node.addWidget('number', 'seed', 5, null, {})
node.serialize_widgets = true
const node2 = new LGraphNode('TestNode2')
node2.addWidget('number', 'seed', 0, null, {})
node2.addWidget('number', 'steps', 0, null, {})
node2.configure(node.serialize())
expect(node2.widgets![0].value).toBe(5) // steps
expect(node2.widgets![1].value).toBe(20) // seed
})
it('should support specifying order for legacy workflows', () => {
node.addWidget('number', 'steps', 0, null, {})
node.addWidget('number', 'seed', 0, null, {})
const nodeData = {
fallbackWidgetsValuesNames: ['seed', 'steps']
} satisfies Partial<ComfyNodeDef> as unknown as ComfyNodeDef
node.constructor = Object.assign({}, node.constructor, { nodeData })
const info: ISerialisedNode = {
id: 1,
type: 'TestNode',
pos: [0, 0],
size: [200, 100],
flags: {},
order: 0,
mode: 0,
widgets_values: [20, 5] // Only serializable widgets
}
node.configure(info)
expect(node.widgets![0].value).toBe(5) // steps
expect(node.widgets![1].value).toBe(20) // seed
})
})
})
describe('sortWidgetValuesByInputOrder', () => {

View File

@@ -338,6 +338,13 @@ export class LiteGraphGlobal {
*/
saveViewportWithGraph: boolean = true
/**
* If `true`, widgets values are deserialised using a map of widget names to values instead of a list.
* This is intended as a temporary setting. It is planned to be made the default and eventually removed.
* @default false
*/
namedValuesRestore: boolean = false
/**
* Enable Vue nodes mode for rendering and positioning.
* When true:

View File

@@ -161,6 +161,7 @@ LiteGraphGlobal {
"macTrackpadGestures": false,
"middle_click_slot_add_default_node": false,
"mouseWheelScroll": "panning",
"namedValuesRestore": false,
"nodeLightness": undefined,
"nodeOpacity": 1,
"node_box_coloured_by_mode": false,

View File

@@ -106,6 +106,7 @@ export interface ISerialisedNode {
* See example in https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite/blob/8629188458dc6cb832f871ece3bd273507e8a766/web/js/VHS.core.js#L59-L84
*/
widgets_values?: TWidgetValue[]
widgets_values_named?: Record<string, TWidgetValue>
}
/** Properties of nodes that are used by subgraph instances. */

View File

@@ -2170,7 +2170,8 @@
"descLabel": "description",
"textPlaceholder": "text to render (verbatim)",
"descPlaceholder": "description of this region",
"colors": "color_palette"
"colors": "color_palette",
"grid": "Grid"
},
"palette": {
"addColor": "Add a color",

View File

@@ -170,6 +170,11 @@ export const useLitegraphSettings = () => {
'Comfy.EnableWorkflowViewRestore'
)
})
watchEffect(() => {
LiteGraph.namedValuesRestore = settingStore.get(
'Comfy.Workflow.NamedValuesRestore'
)
})
watchEffect(() => {
const selectChildren = settingStore.get(

View File

@@ -163,6 +163,13 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.Workflow.NamedValuesRestore',
name: 'Restore widget values by name',
type: 'boolean',
defaultValue: false,
experimental: true
},
{
id: 'Comfy.Canvas.NavigationMode',
category: ['LiteGraph', 'Canvas Navigation', 'NavigationMode'],

View File

@@ -5,7 +5,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useBoundingBoxesWidget } from './useBoundingBoxesWidget'
const widgetOptions = { serialize: true, canvasOnly: false }
const widgetOptions = { serialize: true, canvasOnly: false, hideInPanel: true }
function mockNode() {
return { addWidget: vi.fn(() => ({})) } as unknown as LGraphNode & {

View File

@@ -17,7 +17,8 @@ export const useBoundingBoxesWidget = (): ComfyWidgetConstructorV2 => {
})) ?? []
return node.addWidget('boundingboxes', spec.name, defaultValue, null, {
serialize: true,
canvasOnly: false
canvasOnly: false,
hideInPanel: true
}) as IBaseWidget
}
}

View File

@@ -377,6 +377,7 @@ const zSettings = z.object({
'Comfy.TreeExplorer.ItemPadding': z.number(),
'Comfy.Validation.Workflows': z.boolean(),
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
'Comfy.Workflow.NamedValuesRestore': z.boolean(),
'Comfy.Execution.PreviewMethod': zPreviewMethod,
'Comfy.Workflow.WorkflowTabsPosition': z.enum(['Sidebar', 'Topbar']),
'Comfy.Node.DoubleClickTitleToEdit': z.boolean(),

View File

@@ -121,10 +121,6 @@ async function reencodeAsPngBlob(
})
}
export interface HasInitialMinSize {
_initialMinSize: { width: number; height: number }
}
export const CONFIG = Symbol()
export const GET_CONFIG = Symbol()
@@ -171,6 +167,11 @@ export function getExtraOptionsForWidget(
return options
}
function getMinSize(node: LGraphNode) {
node._initialMinSize ??= { width: 1, height: 1 }
return node._initialMinSize
}
/**
* Service that augments litegraph with ComfyUI specific functionality.
*/
@@ -209,7 +210,7 @@ export const useLitegraphService = () => {
* @internal The key for the node definition in the i18n file.
*/
function nodeKey(node: LGraphNode): string {
return `nodeDefs.${normalizeI18nKey(node.constructor.nodeData!.name)}`
return `nodeDefs.${normalizeI18nKey(node.constructor.nodeData?.name ?? '')}`
}
/**
* @internal Add input sockets to the node. (No widget)
@@ -315,15 +316,9 @@ export const useLitegraphService = () => {
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
})
}
const castedNode = node as LGraphNode & HasInitialMinSize
castedNode._initialMinSize.width = Math.max(
castedNode._initialMinSize.width,
minWidth
)
castedNode._initialMinSize.height = Math.max(
castedNode._initialMinSize.height,
minHeight
)
const minSize = getMinSize(node)
minSize.width = Math.max(minSize.width, minWidth)
minSize.height = Math.max(minSize.height, minHeight)
}
/**
@@ -372,9 +367,9 @@ export const useLitegraphService = () => {
const pad =
node.widgets?.length &&
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
const castedNode = node as LGraphNode & HasInitialMinSize
s[0] = Math.max(castedNode._initialMinSize.width, s[0] + (pad ? 60 : 0))
s[1] = Math.max(castedNode._initialMinSize.height, s[1])
const minSize = getMinSize(node)
s[0] = Math.max(minSize.width, s[0] + (pad ? 60 : 0))
s[1] = Math.max(minSize.height, s[1])
node.setSize(s)
}
@@ -383,17 +378,12 @@ export const useLitegraphService = () => {
subgraph: Subgraph,
instanceData: ExportedSubgraphInstance
) {
const node = class ComfyNode
extends SubgraphNode
implements HasInitialMinSize
{
const node = class ComfyNode extends SubgraphNode {
static comfyClass: string
static override title: string
static override category: string
static override nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
_initialMinSize = { width: 1, height: 1 }
constructor() {
super(app.rootGraph, subgraph, instanceData)
@@ -506,17 +496,12 @@ export const useLitegraphService = () => {
}
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
const node = class ComfyNode
extends LGraphNode
implements HasInitialMinSize
{
const node = class ComfyNode extends LGraphNode {
static comfyClass: string
static override title: string
static override category: string
static override nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
_initialMinSize = { width: 1, height: 1 }
constructor(title: string) {
super(title)
setupStrokeStyles(this)

View File

@@ -195,6 +195,9 @@ declare module '@/lib/litegraph/src/litegraph' {
pasteFile?(file: File): void
/** Callback for pasting multiple files into the node */
pasteFiles?(files: File[]): void
/** Used internally for sizing the node during creation */
_initialMinSize?: { width: number; height: number }
}
/**
* Only used by the Primitive node. Primitive node is using the widget property