Compare commits
16 Commits
version-bu
...
feat/model
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2b5f533f1 | ||
|
|
c607b98130 | ||
|
|
9e9d08febf | ||
|
|
411de899a5 | ||
|
|
4db6d81804 | ||
|
|
58ad02db4e | ||
|
|
e0c9c96052 | ||
|
|
0b2c250c85 | ||
|
|
676226e3ad | ||
|
|
b2176e441a | ||
|
|
f15ef81f03 | ||
|
|
a98d75dbee | ||
|
|
36c7fbfee0 | ||
|
|
3417fbb7e5 | ||
|
|
6ed81e05db | ||
|
|
08354d74a3 |
@@ -38,7 +38,7 @@ test.describe('Download page @smoke', () => {
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).toHaveAttribute('target', '_blank')
|
||||
|
||||
@@ -176,7 +176,7 @@ test.describe('Download page mobile @mobile', () => {
|
||||
level: 1
|
||||
})
|
||||
})
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD LOCAL/i })
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
|
||||
@@ -213,7 +213,7 @@ test.describe('Get started section links @smoke', () => {
|
||||
has: page.getByRole('heading', { name: 'Get started in minutes' })
|
||||
})
|
||||
|
||||
const downloadLink = section.getByRole('link', { name: 'Download Desktop' })
|
||||
const downloadLink = section.getByRole('link', { name: 'Download Local' })
|
||||
await expect(downloadLink).toBeVisible()
|
||||
await expect(downloadLink).toHaveAttribute('href', '/download')
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ test.describe('Desktop navigation @smoke', () => {
|
||||
const nav = page.getByRole('navigation', { name: 'Main navigation' })
|
||||
const desktopCTA = nav.getByTestId('desktop-nav-cta')
|
||||
await expect(
|
||||
desktopCTA.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
|
||||
desktopCTA.getByRole('link', { name: 'DOWNLOAD LOCAL' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
desktopCTA.getByRole('link', { name: 'LAUNCH CLOUD' })
|
||||
@@ -55,7 +55,7 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
|
||||
const dropdown = productsButton.locator('..').getByTestId('nav-dropdown')
|
||||
for (const item of [
|
||||
'Comfy Desktop',
|
||||
'Comfy Local',
|
||||
'Comfy Cloud',
|
||||
'Comfy API',
|
||||
'Comfy Enterprise'
|
||||
@@ -69,7 +69,7 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
|
||||
await page.locator('main').hover()
|
||||
@@ -81,7 +81,7 @@ test.describe('Desktop dropdown @interaction', () => {
|
||||
const desktopLinks = nav.getByTestId('desktop-nav-links')
|
||||
await desktopLinks.getByRole('button', { name: /PRODUCTS/i }).hover()
|
||||
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Desktop' }).first()
|
||||
const comfyLocal = nav.getByRole('link', { name: 'Comfy Local' }).first()
|
||||
await expect(comfyLocal).toBeVisible()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
@@ -121,7 +121,7 @@ test.describe('Mobile menu @mobile', () => {
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await menu.getByText('PRODUCTS').first().click()
|
||||
|
||||
await expect(menu.getByText('Comfy Desktop')).toBeVisible()
|
||||
await expect(menu.getByText('Comfy Local')).toBeVisible()
|
||||
await expect(menu.getByText('Comfy Cloud')).toBeVisible()
|
||||
|
||||
await menu.getByRole('button', { name: /BACK/i }).click()
|
||||
@@ -133,7 +133,7 @@ test.describe('Mobile menu @mobile', () => {
|
||||
|
||||
const menu = page.locator('#site-mobile-menu')
|
||||
await expect(
|
||||
menu.getByRole('link', { name: 'DOWNLOAD DESKTOP' })
|
||||
menu.getByRole('link', { name: 'DOWNLOAD LOCAL' })
|
||||
).toBeVisible()
|
||||
await expect(menu.getByRole('link', { name: 'LAUNCH CLOUD' })).toBeVisible()
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
@@ -58,7 +58,7 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
runway: { name: 'Runway', slug: 'runway' },
|
||||
vidu: { name: 'Vidu', slug: 'vidu' },
|
||||
bfl: { name: 'Flux (API)', slug: 'flux-api' },
|
||||
grok: { name: 'Grok Image', slug: 'grok-image' },
|
||||
grok: { name: 'Grok Imagine', slug: 'grok-imagine' },
|
||||
stability: { name: 'Stability AI', slug: 'stability-ai' },
|
||||
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
|
||||
@@ -86,6 +86,20 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
|
||||
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
|
||||
}
|
||||
|
||||
// Stub entries that exist only to issue 301 redirects from old slugs to
|
||||
// their new canonical slugs. Keeps renames reproducible across regenerations.
|
||||
const LEGACY_SLUG_REDIRECTS: OutputModel[] = [
|
||||
{
|
||||
slug: 'grok-image',
|
||||
canonicalSlug: 'grok-imagine',
|
||||
name: 'Grok Image',
|
||||
displayName: 'Grok Image',
|
||||
directory: 'partner_nodes',
|
||||
huggingFaceUrl: '',
|
||||
workflowCount: 0
|
||||
}
|
||||
]
|
||||
|
||||
function stripExt(name: string): string {
|
||||
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
|
||||
}
|
||||
@@ -299,7 +313,8 @@ function run(): void {
|
||||
throw new Error(
|
||||
`Failed to parse ${file}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
}`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -367,7 +382,7 @@ function run(): void {
|
||||
displayName: m.name
|
||||
}))
|
||||
|
||||
const combined = [...apiOutput, ...output]
|
||||
const combined = [...apiOutput, ...output, ...LEGACY_SLUG_REDIRECTS]
|
||||
|
||||
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
|
||||
process.stdout.write(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const {
|
||||
logoSrc = '/icons/logo.svg',
|
||||
logoAlt = 'Comfy',
|
||||
text = 'DESKTOP'
|
||||
text = 'LOCAL'
|
||||
} = defineProps<{
|
||||
logoSrc?: string
|
||||
logoAlt?: string
|
||||
@@ -20,7 +20,7 @@ const {
|
||||
/>
|
||||
|
||||
<span
|
||||
class="bg-primary-comfy-yellow my-auto flex h-12 items-center justify-center text-primary-comfy-ink lg:my-0 lg:h-auto lg:p-8"
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-12 items-center justify-center lg:my-0 lg:h-auto lg:p-8"
|
||||
>
|
||||
<img
|
||||
:src="logoSrc"
|
||||
@@ -37,7 +37,7 @@ const {
|
||||
/>
|
||||
|
||||
<span
|
||||
class="bg-primary-comfy-yellow my-auto flex h-7.25 items-center justify-center text-primary-comfy-ink lg:h-15.5 lg:px-6"
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink my-auto flex h-7.25 items-center justify-center lg:h-15.5 lg:px-6"
|
||||
>
|
||||
<span
|
||||
class="inline-block translate-y-0.5 text-2xl leading-none font-bold lg:text-3xl"
|
||||
|
||||
@@ -94,7 +94,7 @@ const ctaButtons = [
|
||||
{
|
||||
label: t('nav.downloadLocal', locale),
|
||||
prefix: 'DOWNLOAD',
|
||||
core: 'DESKTOP',
|
||||
core: 'LOCAL',
|
||||
href: routes.download,
|
||||
primary: false
|
||||
},
|
||||
@@ -164,7 +164,7 @@ onMounted(() => {
|
||||
/>
|
||||
|
||||
<nav
|
||||
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
class="bg-primary-comfy-ink fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
<a
|
||||
|
||||
@@ -7,12 +7,16 @@ const {
|
||||
item,
|
||||
locale = 'en',
|
||||
aspect = 'var(--aspect-ratio-gallery-card)',
|
||||
mobile = false
|
||||
mobile = false,
|
||||
objectPosition = 'center',
|
||||
objectFit = 'cover'
|
||||
} = defineProps<{
|
||||
item: GalleryItem
|
||||
locale?: Locale
|
||||
aspect?: string
|
||||
mobile?: boolean
|
||||
objectPosition?: string
|
||||
objectFit?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{ click: [] }>()
|
||||
@@ -31,13 +35,15 @@ defineEmits<{ click: [] }>()
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
class="size-full transition-transform duration-300 group-hover:scale-105"
|
||||
:style="{ objectPosition, objectFit }"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="item.image"
|
||||
:alt="item.title"
|
||||
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
class="size-full transition-transform duration-300 group-hover:scale-105"
|
||||
:style="{ objectPosition, objectFit }"
|
||||
/>
|
||||
<!-- Desktop hover overlay -->
|
||||
<div
|
||||
|
||||
152
apps/website/src/components/models/ModelCreationsSection.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { GalleryItem } from '../../data/gallery'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import GalleryCard from '../gallery/GalleryCard.vue'
|
||||
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const modelName = 'Grok'
|
||||
const ctaHref = 'https://comfy.org/workflows/model/grok'
|
||||
|
||||
const items: GalleryItem[] = [
|
||||
{
|
||||
id: 'subway-swan',
|
||||
image: 'https://media.comfy.org/website/gallery/subway-swan_compressed.png',
|
||||
title: 'Subway Swan',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
},
|
||||
{
|
||||
id: 'milos-little-wonder',
|
||||
video:
|
||||
'https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4',
|
||||
title: 'Milos Little Wonder',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
},
|
||||
{
|
||||
id: 'amber-passage',
|
||||
image:
|
||||
'https://media.comfy.org/website/gallery/amber-passage_compressed.jpg',
|
||||
title: 'Amber Passage',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats',
|
||||
objectPosition: 'bottom'
|
||||
},
|
||||
{
|
||||
id: 'neon-revenant',
|
||||
video:
|
||||
'https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4',
|
||||
title: 'Neon Revenant',
|
||||
userAlias: 'Eric Solorio',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.instagram.com/enigmatic_e'
|
||||
},
|
||||
{
|
||||
id: 'midnight-umami',
|
||||
image:
|
||||
'https://media.comfy.org/website/gallery/midnight_umami_compressed.png',
|
||||
title: 'Midnight Umami',
|
||||
userAlias: 'Purz Beats',
|
||||
teamAlias: 'Comfy',
|
||||
tool: 'Grok Imagine',
|
||||
href: 'https://www.youtube.com/@PurzBeats'
|
||||
}
|
||||
]
|
||||
|
||||
const modalOpen = ref(false)
|
||||
const modalIndex = ref(0)
|
||||
|
||||
function openDetail(index: number) {
|
||||
modalIndex.value = index
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
const title = t('models.list.creations.title', locale).replace(
|
||||
'{name}',
|
||||
modelName
|
||||
)
|
||||
const ctaLabel = t('models.list.creations.cta', locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
data-testid="model-creations"
|
||||
class="flex flex-col items-center px-4 py-16 lg:px-20 lg:pt-36"
|
||||
>
|
||||
<h2
|
||||
class="max-w-4xl text-center text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<BrandButton
|
||||
:href="ctaHref"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="mt-16 px-8 py-4 uppercase"
|
||||
>
|
||||
{{ ctaLabel }}
|
||||
</BrandButton>
|
||||
|
||||
<div class="mt-20 hidden w-full flex-col gap-2 lg:flex">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items.slice(0, 2)"
|
||||
:key="i"
|
||||
:item
|
||||
:locale
|
||||
:object-position="item.objectPosition"
|
||||
:object-fit="item.objectFit"
|
||||
@click="openDetail(i)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="items.length > 2" class="grid grid-cols-3 gap-2">
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items.slice(2, 5)"
|
||||
:key="i + 2"
|
||||
:item
|
||||
:locale
|
||||
:object-position="item.objectPosition"
|
||||
:object-fit="item.objectFit"
|
||||
@click="openDetail(i + 2)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-5xl bg-transparency-white-t4 mt-12 flex w-full flex-col gap-6 p-2 max-lg:pb-6 lg:hidden"
|
||||
>
|
||||
<GalleryCard
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
:item
|
||||
:locale
|
||||
:object-position="item.objectPosition"
|
||||
:object-fit="item.objectFit"
|
||||
mobile
|
||||
@click="openDetail(i)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GalleryDetailModal
|
||||
v-if="modalOpen"
|
||||
:items
|
||||
:initial-index="modalIndex"
|
||||
:locale
|
||||
@close="modalOpen = false"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
62
apps/website/src/components/models/ModelsHeroSection.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
modelName,
|
||||
ctaHref,
|
||||
videoSrc,
|
||||
videoAriaLabel
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
modelName: string
|
||||
ctaHref: string
|
||||
videoSrc: string
|
||||
videoAriaLabel?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col items-center px-6 pt-16 text-center lg:pt-36">
|
||||
<h1
|
||||
class="max-w-4xl text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{
|
||||
t('models.list.heroTitle.before', locale).replace('{name}', modelName)
|
||||
}}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span>
|
||||
{{
|
||||
t('models.list.heroTitle.after', locale).replace('{name}', modelName)
|
||||
}}
|
||||
</h1>
|
||||
<p
|
||||
class="mt-6 max-w-2xl text-sm text-pretty text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ t('hero.subtitle', locale) }}
|
||||
</p>
|
||||
<BrandButton
|
||||
:href="ctaHref"
|
||||
variant="solid"
|
||||
size="lg"
|
||||
class="mt-10 px-8 py-4 uppercase"
|
||||
>
|
||||
{{ t('models.list.heroCta', locale).replace('{name}', modelName) }}
|
||||
</BrandButton>
|
||||
<div class="mt-16 w-full max-w-5xl">
|
||||
<video
|
||||
:src="videoSrc"
|
||||
:aria-label="videoAriaLabel || undefined"
|
||||
:aria-hidden="videoAriaLabel ? undefined : true"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
class="rounded-4.5xl size-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -283,16 +283,16 @@ onUnmounted(() => {
|
||||
<div
|
||||
class="relative z-10 mt-17 w-full px-4 pb-16 lg:mt-0 lg:min-w-160 lg:flex-1 lg:translate-x-[10%] lg:px-20 lg:py-14"
|
||||
>
|
||||
<ProductHeroBadge text="DESKTOP" />
|
||||
<ProductHeroBadge />
|
||||
|
||||
<h1
|
||||
class="mt-6 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
|
||||
class="text-primary-comfy-canvas mt-6 text-3xl/tight font-light whitespace-pre-line md:text-4xl/tight lg:max-w-2xl lg:text-5xl/tight"
|
||||
>
|
||||
{{ t('download.hero.heading', locale) }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="mt-6 max-w-md text-sm text-primary-comfy-canvas lg:mt-6 lg:text-base"
|
||||
class="text-primary-comfy-canvas mt-6 max-w-md text-sm lg:mt-6 lg:text-base"
|
||||
>
|
||||
{{ t('download.hero.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
@@ -78,7 +78,7 @@ function getCardClass(layoutClass: string): string {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
|
||||
class="max-w-9xl mx-auto bg-primary-comfy-ink px-4 py-16 lg:px-20 lg:py-40"
|
||||
>
|
||||
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
|
||||
<p
|
||||
@@ -88,18 +88,18 @@ function getCardClass(layoutClass: string): string {
|
||||
</p>
|
||||
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3.5xl/tight mt-8 max-w-4xl text-center font-light lg:text-5xl"
|
||||
class="text-3.5xl/tight mt-8 max-w-4xl text-center font-light text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('cloud.aiModels.heading', locale) }}
|
||||
</h2>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas mt-8 max-w-xl text-center text-sm font-light lg:text-base/snug"
|
||||
class="mt-8 max-w-xl text-center text-sm font-light text-primary-comfy-canvas lg:text-base/snug"
|
||||
>
|
||||
{{ t('cloud.aiModels.subtitle', locale) }}
|
||||
</p>
|
||||
|
||||
<div class="mt-24 w-full">
|
||||
<div class="mt-16 w-full lg:mt-24">
|
||||
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
|
||||
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
|
||||
<a
|
||||
@@ -180,14 +180,15 @@ function getCardClass(layoutClass: string): string {
|
||||
<BrandButton
|
||||
:href="externalLinks.workflows"
|
||||
variant="outline"
|
||||
class="mt-4 w-full max-w-md text-center lg:mt-8 lg:w-auto"
|
||||
size="lg"
|
||||
class="mt-4 w-full max-w-md px-8 py-4 text-center lg:mt-8 lg:w-auto"
|
||||
>
|
||||
<span class="lg:hidden">{{
|
||||
t('cloud.aiModels.ctaMobile', locale)
|
||||
}}</span>
|
||||
<span class="hidden lg:inline">{{
|
||||
<!-- <span class="lg:hidden"> -->
|
||||
{{ t('cloud.aiModels.ctaMobile', locale) }}
|
||||
<!-- </span> -->
|
||||
<!-- <span class="hidden lg:inline">{{
|
||||
t('cloud.aiModels.ctaDesktop', locale)
|
||||
}}</span>
|
||||
}}</span> -->
|
||||
</BrandButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -90,7 +90,7 @@ export const modelMetadata: Record<string, ModelOverride> = {
|
||||
hubSlug: 'seedance',
|
||||
featured: true
|
||||
},
|
||||
'grok-image': {
|
||||
'grok-imagine': {
|
||||
hubSlug: 'grok',
|
||||
featured: false
|
||||
},
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface GalleryItem {
|
||||
teamAlias: string
|
||||
tool: string
|
||||
href?: string
|
||||
objectPosition?: string
|
||||
objectFit?: string
|
||||
/** Defaults to true. Set to false to hide this item from rendered lists. */
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
@@ -124,8 +124,8 @@ const translations = {
|
||||
'zh-CN': '下载或注册'
|
||||
},
|
||||
'getStarted.step1.downloadLocal': {
|
||||
en: 'Download Desktop',
|
||||
'zh-CN': '下载桌面版'
|
||||
en: 'Download Local',
|
||||
'zh-CN': '下载本地版'
|
||||
},
|
||||
'getStarted.step1.launchCloud': {
|
||||
en: 'Launch Cloud',
|
||||
@@ -605,8 +605,8 @@ const translations = {
|
||||
'是的。基于 GPL-3.0 免费开源。没有功能限制、没有试用期、没有附加条件。'
|
||||
},
|
||||
'download.faq.4.q': {
|
||||
en: 'Why would I pay for Comfy Cloud if Desktop is free?',
|
||||
'zh-CN': '既然桌面版免费,为什么还要付费使用 Comfy Cloud?'
|
||||
en: 'Why would I pay for Comfy Cloud if Local is free?',
|
||||
'zh-CN': '既然本地版免费,为什么还要付费使用 Comfy Cloud?'
|
||||
},
|
||||
'download.faq.4.a': {
|
||||
en: 'Your machine or ours. Cloud gives you powerful GPUs on demand, pre-loaded models, end-to-end security and infrastructure out of the box and partner models cleared for commercial use.',
|
||||
@@ -623,8 +623,8 @@ const translations = {
|
||||
'Desktop:一键安装,自动更新。Portable:独立构建,可从任意文件夹运行。CLI:从 GitHub 克隆,完全开发者控制,适合想自定义环境或参与上游贡献的开发者。'
|
||||
},
|
||||
'download.faq.6.q': {
|
||||
en: 'Can I use my Desktop workflows in Comfy Cloud?',
|
||||
'zh-CN': '我可以在 Comfy Cloud 中使用桌面工作流吗?'
|
||||
en: 'Can I use my local workflows in Comfy Cloud?',
|
||||
'zh-CN': '我可以在 Comfy Cloud 中使用本地工作流吗?'
|
||||
},
|
||||
'download.faq.6.a': {
|
||||
en: 'Yes — same file, same results. No conversion, no rework.',
|
||||
@@ -665,8 +665,8 @@ const translations = {
|
||||
'zh-CN': '专业人士为何\n选择'
|
||||
},
|
||||
'download.reason.headingHighlight': {
|
||||
en: 'Desktop',
|
||||
'zh-CN': '桌面版'
|
||||
en: 'Local',
|
||||
'zh-CN': '本地版'
|
||||
},
|
||||
'download.reason.1.title': {
|
||||
en: 'Unlimited\nCustomization',
|
||||
@@ -715,8 +715,8 @@ const translations = {
|
||||
'zh-CN': '完整的 ComfyUI 引擎——开源、快速、可扩展,随你运行。'
|
||||
},
|
||||
'download.hero.downloadLocal': {
|
||||
en: 'DOWNLOAD DESKTOP',
|
||||
'zh-CN': '下载桌面版'
|
||||
en: 'DOWNLOAD LOCAL',
|
||||
'zh-CN': '下载本地版'
|
||||
},
|
||||
'download.hero.installGithub': {
|
||||
en: 'INSTALL FROM GITHUB',
|
||||
@@ -1810,7 +1810,7 @@ const translations = {
|
||||
'nav.community': { en: 'Community', 'zh-CN': '社区' },
|
||||
'nav.resources': { en: 'Resources', 'zh-CN': '资源' },
|
||||
'nav.company': { en: 'Company', 'zh-CN': '公司' },
|
||||
'nav.comfyLocal': { en: 'Comfy Desktop', 'zh-CN': 'Comfy 桌面版' },
|
||||
'nav.comfyLocal': { en: 'Comfy Local', 'zh-CN': 'Comfy 本地版' },
|
||||
'nav.comfyCloud': { en: 'Comfy Cloud', 'zh-CN': 'Comfy Cloud' },
|
||||
'nav.comfyApi': { en: 'Comfy API', 'zh-CN': 'Comfy API' },
|
||||
'nav.comfyEnterprise': {
|
||||
@@ -1828,7 +1828,7 @@ const translations = {
|
||||
'nav.aboutUs': { en: 'About Us', 'zh-CN': '关于我们' },
|
||||
'nav.careers': { en: 'Careers', 'zh-CN': '招聘' },
|
||||
'nav.customerStories': { en: 'Customer Stories', 'zh-CN': '客户故事' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD DESKTOP', 'zh-CN': '下载桌面版' },
|
||||
'nav.downloadLocal': { en: 'DOWNLOAD LOCAL', 'zh-CN': '下载本地版' },
|
||||
'nav.launchCloud': { en: 'LAUNCH CLOUD', 'zh-CN': '启动云端' },
|
||||
'nav.menu': { en: 'Menu', 'zh-CN': '菜单' },
|
||||
'nav.toggleMenu': { en: 'Toggle menu', 'zh-CN': '切换菜单' },
|
||||
@@ -1879,9 +1879,8 @@ const translations = {
|
||||
'如果我们的网站包含指向第三方网站和服务的链接,请注意这些网站和服务有自己的隐私政策。在访问任何第三方内容的链接后,您应阅读其发布的关于如何收集和使用个人信息的隐私政策信息。本隐私政策不适用于您离开我们网站后的任何活动。'
|
||||
},
|
||||
'privacy.intro.block.3': {
|
||||
en: 'This policy is effective as of April 18, 2025. For information specific to Comfy Desktop (the local install application), including named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights, see our <a href="/privacy/desktop" class="text-white underline">Desktop Privacy Policy</a>.',
|
||||
'zh-CN':
|
||||
'本政策自 2025 年 4 月 18 日起生效。有关 Comfy Desktop(本地安装应用程序)的具体信息,包括指定的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限以及您的权利,请参阅我们的<a href="/zh-CN/privacy/desktop" class="text-white underline">Desktop 隐私政策</a>。'
|
||||
en: 'This policy is effective as of April 18, 2025.',
|
||||
'zh-CN': '本政策自 2025 年 4 月 18 日起生效。'
|
||||
},
|
||||
'privacy.information-we-collect.label': {
|
||||
en: 'INFORMATION',
|
||||
@@ -2131,181 +2130,6 @@ const translations = {
|
||||
'<a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>'
|
||||
},
|
||||
|
||||
// ── Desktop Privacy Policy ────────────────────────────────────────
|
||||
'desktop_privacy.intro.label': { en: 'OVERVIEW', 'zh-CN': 'OVERVIEW' },
|
||||
'desktop_privacy.intro.block.0': {
|
||||
en: 'Effective 3 June 2026. Applies to the Comfy Desktop application.',
|
||||
'zh-CN': 'Effective 3 June 2026. Applies to the Comfy Desktop application.'
|
||||
},
|
||||
'desktop_privacy.intro.block.1': {
|
||||
en: 'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.',
|
||||
'zh-CN':
|
||||
'This Privacy Policy describes the personal data we process when you use Comfy Desktop, the purposes and lawful bases for that processing, the recipients of the data, and the rights available to you. The same policy is shown in the application on first run and is available at any time from Settings → About → Privacy Policy.'
|
||||
},
|
||||
|
||||
'desktop_privacy.controller.label': {
|
||||
en: 'CONTROLLER',
|
||||
'zh-CN': 'CONTROLLER'
|
||||
},
|
||||
'desktop_privacy.controller.title': {
|
||||
en: 'Controller',
|
||||
'zh-CN': 'Controller'
|
||||
},
|
||||
'desktop_privacy.controller.block.0': {
|
||||
en: 'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'Comfy Organization Inc ("Comfy Org", "we", "us") is the data controller for personal data processed in connection with your use of Comfy Desktop. We are established in San Francisco, USA. For privacy enquiries: <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
|
||||
'desktop_privacy.data.label': {
|
||||
en: 'DATA WE PROCESS',
|
||||
'zh-CN': 'DATA WE PROCESS'
|
||||
},
|
||||
'desktop_privacy.data.title': {
|
||||
en: 'Personal data we process',
|
||||
'zh-CN': 'Personal data we process'
|
||||
},
|
||||
'desktop_privacy.data.block.0': {
|
||||
en: 'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:',
|
||||
'zh-CN':
|
||||
'If you have enabled telemetry, either on the first-run consent screen or at Settings → Telemetry, we process the following categories of data:'
|
||||
},
|
||||
'desktop_privacy.data.block.1': {
|
||||
en: 'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.',
|
||||
'zh-CN':
|
||||
'Device identifier. A pseudonymous identifier generated locally on first run. Before you sign in to Comfy Cloud it is not linked to your name, email address, or hardware. When you sign in, it is associated with your Comfy account.\nTechnical metadata. Application version, operating system, and processor architecture.\nProduct usage events. Feature interactions, navigation between views, installation and update milestones, and approximate timing.\nCustom node identifiers. Public package names of custom nodes you install through Manager (for example, "comfyui-impact-pack"). The local installation path is not transmitted.\nCrash and error diagnostics. Stack traces, error messages, and short stdout/stderr fragments captured at the moment of failure.'
|
||||
},
|
||||
'desktop_privacy.data.block.2': {
|
||||
en: 'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).',
|
||||
'zh-CN':
|
||||
'Before crash or error diagnostic data is transmitted, we apply automated redaction to home-directory paths and to well-known credential patterns (Bearer tokens, OpenAI <code>sk-*</code> and Hugging Face <code>hf_*</code> keys, basic-auth URLs, and <code>KEY=</code> / <code>SECRET=</code> environment assignments).'
|
||||
},
|
||||
'desktop_privacy.data.block.3': {
|
||||
en: 'We do not process:',
|
||||
'zh-CN': 'We do not process:'
|
||||
},
|
||||
'desktop_privacy.data.block.4': {
|
||||
en: 'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application',
|
||||
'zh-CN':
|
||||
'Workflow content (the graph, the nodes you connect, their parameters)\nPrompts you write\nGenerated images, video, or audio\nModel weights, or the local filenames under which you save them\nNetwork activity outside the application'
|
||||
},
|
||||
'desktop_privacy.data.block.5': {
|
||||
en: 'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.',
|
||||
'zh-CN':
|
||||
'Your workflow files, your models, the outputs you generate, the list of installations you create, and your local settings remain on your device. They are not transmitted to Comfy Org, and they are not accessible to us.'
|
||||
},
|
||||
|
||||
'desktop_privacy.purposes.label': { en: 'PURPOSES', 'zh-CN': 'PURPOSES' },
|
||||
'desktop_privacy.purposes.title': {
|
||||
en: 'Purposes and lawful bases',
|
||||
'zh-CN': 'Purposes and lawful bases'
|
||||
},
|
||||
'desktop_privacy.purposes.block.0': {
|
||||
en: 'We process personal data on the following lawful bases under GDPR and UK GDPR:',
|
||||
'zh-CN':
|
||||
'We process personal data on the following lawful bases under GDPR and UK GDPR:'
|
||||
},
|
||||
'desktop_privacy.purposes.block.1': {
|
||||
en: 'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).',
|
||||
'zh-CN':
|
||||
'Product usage analytics: consent under Article 6(1)(a).\nCrash and error diagnostics: consent under Article 6(1)(a).\nDelivery of software updates and integrity verification: legitimate interests under Article 6(1)(f).\nAuthentication when you sign in to Comfy Cloud: performance of a contract under Article 6(1)(b).'
|
||||
},
|
||||
'desktop_privacy.purposes.block.2': {
|
||||
en: 'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'Consent for analytics and crash diagnostics is opt-in, and you may withdraw it at any time at Settings → Telemetry. Withdrawal does not affect the lawfulness of processing carried out before withdrawal. To object to processing on the basis of legitimate interests, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
'desktop_privacy.purposes.block.3': {
|
||||
en: 'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.',
|
||||
'zh-CN':
|
||||
'We do not carry out automated decision-making, including profiling, that produces legal or similarly significant effects. We do not sell personal data, and we do not share personal data for cross-context behavioural advertising.'
|
||||
},
|
||||
|
||||
'desktop_privacy.processors.label': {
|
||||
en: 'RECIPIENTS',
|
||||
'zh-CN': 'RECIPIENTS'
|
||||
},
|
||||
'desktop_privacy.processors.title': {
|
||||
en: 'Recipients',
|
||||
'zh-CN': 'Recipients'
|
||||
},
|
||||
'desktop_privacy.processors.block.0': {
|
||||
en: 'We engage the following processors under Data Processing Agreements:',
|
||||
'zh-CN':
|
||||
'We engage the following processors under Data Processing Agreements:'
|
||||
},
|
||||
'desktop_privacy.processors.block.1': {
|
||||
en: 'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)',
|
||||
'zh-CN':
|
||||
'PostHog (product usage analytics)\nDatadog (crash and error diagnostics)\nToDesktop (application distribution and software updates)\nComfy Org analytics warehouse (long-term aggregate analytics, operated by Comfy Org)'
|
||||
},
|
||||
|
||||
'desktop_privacy.transfers.label': { en: 'TRANSFERS', 'zh-CN': 'TRANSFERS' },
|
||||
'desktop_privacy.transfers.title': {
|
||||
en: 'International transfers',
|
||||
'zh-CN': 'International transfers'
|
||||
},
|
||||
'desktop_privacy.transfers.block.0': {
|
||||
en: 'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.',
|
||||
'zh-CN':
|
||||
'Comfy Organization Inc is established in the United States. Personal data of users in the EU, UK, EEA, or other jurisdictions outside the United States may be transferred to the United States and to other locations where our processors operate. Where required, we rely on the European Commission Standard Contractual Clauses (and the UK International Data Transfer Addendum where applicable) as the transfer mechanism under Chapter V GDPR.'
|
||||
},
|
||||
|
||||
'desktop_privacy.retention.label': { en: 'RETENTION', 'zh-CN': 'RETENTION' },
|
||||
'desktop_privacy.retention.title': { en: 'Retention', 'zh-CN': 'Retention' },
|
||||
'desktop_privacy.retention.block.0': {
|
||||
en: 'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.',
|
||||
'zh-CN':
|
||||
'Product usage analytics: up to 24 months from the event, then aggregated or deleted.\nCrash and error diagnostics: 15 days at full fidelity, then sampled or aggregated.\nAggregate analytics: up to 36 months in aggregated form.\nUpdate-server logs: 90 days.\nLocal device identifier: stored on your device only, and removed when you uninstall the application.'
|
||||
},
|
||||
|
||||
'desktop_privacy.rights.label': { en: 'YOUR RIGHTS', 'zh-CN': 'YOUR RIGHTS' },
|
||||
'desktop_privacy.rights.title': { en: 'Your rights', 'zh-CN': 'Your rights' },
|
||||
'desktop_privacy.rights.block.0': {
|
||||
en: 'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.',
|
||||
'zh-CN':
|
||||
'If you are in the EU, UK, or EEA, you have the following rights under GDPR and UK GDPR: access, rectification, erasure, restriction of processing, objection, portability, and withdrawal of consent.'
|
||||
},
|
||||
'desktop_privacy.rights.block.1': {
|
||||
en: 'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.',
|
||||
'zh-CN':
|
||||
'If you are a California resident, you have rights under CCPA and CPRA: to know what we collect, to delete, to correct, and to limit use of sensitive personal information. We do not sell personal information, and we do not share it for cross-context behavioural advertising.'
|
||||
},
|
||||
'desktop_privacy.rights.block.2': {
|
||||
en: "You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency.",
|
||||
'zh-CN':
|
||||
"You also have the right to lodge a complaint with your supervisory authority, such as the UK Information Commissioner's Office, your EU member-state data protection authority, or the California Privacy Protection Agency."
|
||||
},
|
||||
'desktop_privacy.rights.block.3': {
|
||||
en: 'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.',
|
||||
'zh-CN':
|
||||
'To exercise any of these rights, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>. If you have signed in to Comfy Cloud, your account verifies your identity. If you have not signed in, please tell us your approximate install date, platform, and application version, and we will attempt to match these against our records. We aim to respond within 30 days.'
|
||||
},
|
||||
|
||||
'desktop_privacy.children.label': { en: 'CHILDREN', 'zh-CN': 'CHILDREN' },
|
||||
'desktop_privacy.children.title': { en: 'Children', 'zh-CN': 'Children' },
|
||||
'desktop_privacy.children.block.0': {
|
||||
en: 'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.',
|
||||
'zh-CN':
|
||||
'Comfy Desktop is not intended for, and we do not knowingly collect personal data from, individuals under 13 years of age.'
|
||||
},
|
||||
|
||||
'desktop_privacy.changes.label': { en: 'CHANGES', 'zh-CN': 'CHANGES' },
|
||||
'desktop_privacy.changes.title': { en: 'Changes', 'zh-CN': 'Changes' },
|
||||
'desktop_privacy.changes.block.0': {
|
||||
en: 'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.',
|
||||
'zh-CN':
|
||||
'We will revise this Privacy Policy when our processing changes materially. The Effective date at the top of this policy reflects the date of the most recent revision.'
|
||||
},
|
||||
|
||||
'desktop_privacy.contact.label': { en: 'CONTACT', 'zh-CN': 'CONTACT' },
|
||||
'desktop_privacy.contact.title': { en: 'Contact', 'zh-CN': 'Contact' },
|
||||
'desktop_privacy.contact.block.0': {
|
||||
en: 'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.',
|
||||
'zh-CN':
|
||||
'For any privacy enquiry, contact <a href="mailto:support@comfy.org" class="text-white underline">support@comfy.org</a>.'
|
||||
},
|
||||
|
||||
// ── Terms of Service ──────────────────────────────────────────────
|
||||
'tos.effectiveDateLabel': {
|
||||
en: 'Effective Date',
|
||||
@@ -4634,6 +4458,80 @@ const translations = {
|
||||
'zh-CN': '支持的模型'
|
||||
},
|
||||
|
||||
// Models list page (/models)
|
||||
'models.list.label': { en: 'MODELS', 'zh-CN': '模型' },
|
||||
'models.list.heroCta': {
|
||||
en: 'Try {name} Now',
|
||||
'zh-CN': '立即试用 {name}'
|
||||
},
|
||||
'models.list.creations.title': {
|
||||
en: '{name} Image and Video Creations',
|
||||
'zh-CN': '{name} 图像与视频创作'
|
||||
},
|
||||
'models.list.creations.cta': {
|
||||
en: 'Explore Workflows',
|
||||
'zh-CN': '探索工作流'
|
||||
},
|
||||
'models.list.heroTitle.before': {
|
||||
en: '{name} in',
|
||||
'zh-CN': ''
|
||||
},
|
||||
'models.list.heroTitle.after': {
|
||||
en: '',
|
||||
'zh-CN': ' 中的 {name}'
|
||||
},
|
||||
'models.list.heroSubtitle': {
|
||||
en: 'From open-source diffusion checkpoints to partner APIs — every major model, with community workflow templates ready to run.',
|
||||
'zh-CN':
|
||||
'从开源扩散模型到合作伙伴 API,涵盖每一个主流模型,并附带可直接运行的社区工作流模板。'
|
||||
},
|
||||
'models.list.card.workflows': {
|
||||
en: '{count} workflows',
|
||||
'zh-CN': '{count} 个工作流'
|
||||
},
|
||||
'models.list.contact.label': {
|
||||
en: 'COMFY HUB',
|
||||
'zh-CN': 'COMFY HUB'
|
||||
},
|
||||
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
|
||||
'models.showcase.heading': {
|
||||
en: 'Run the world’s\nleading AI models',
|
||||
'zh-CN': '运行全球领先的\nAI 模型'
|
||||
},
|
||||
'models.showcase.subtitle': {
|
||||
en: 'New models are added as they launch.',
|
||||
'zh-CN': '新模型发布后会第一时间上线。'
|
||||
},
|
||||
'models.showcase.cta': {
|
||||
en: 'EXPLORE WORKFLOWS',
|
||||
'zh-CN': '探索工作流'
|
||||
},
|
||||
'models.showcase.card.grokImagine': {
|
||||
en: 'Grok Imagine',
|
||||
'zh-CN': 'Grok Imagine'
|
||||
},
|
||||
'models.showcase.card.nanoBananaPro': {
|
||||
en: 'Nano Banana Pro',
|
||||
'zh-CN': 'Nano Banana Pro'
|
||||
},
|
||||
'models.showcase.card.ltx23': {
|
||||
en: 'LTX 2.3',
|
||||
'zh-CN': 'LTX 2.3'
|
||||
},
|
||||
'models.showcase.card.qwenAdvancedEdit': {
|
||||
en: 'Advanced image\nediting with Qwen',
|
||||
'zh-CN': '使用 Qwen 进行\n高级图像编辑'
|
||||
},
|
||||
'models.showcase.card.wan22TextToVideo': {
|
||||
en: 'Wan 2.2\ntext to video',
|
||||
'zh-CN': 'Wan 2.2\n文字转视频'
|
||||
},
|
||||
'models.list.contact.heading': {
|
||||
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
|
||||
'zh-CN':
|
||||
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
|
||||
},
|
||||
|
||||
// Payment status pages
|
||||
'payment.success.label': {
|
||||
en: 'PAYMENT',
|
||||
|
||||
22
apps/website/src/pages/models.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
|
||||
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
|
||||
import AIModelsSection from '../components/product/shared/AIModelsSection.vue'
|
||||
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Models — Comfy"
|
||||
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
|
||||
>
|
||||
<ModelsHeroSection
|
||||
modelName="Grok Imagine"
|
||||
ctaHref="/p/supported-models/grok-imagine"
|
||||
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
|
||||
videoAriaLabel="Grok Imagine output created with ComfyUI"
|
||||
/>
|
||||
<ModelCreationsSection client:load />
|
||||
<AIModelsSection client:load />
|
||||
<ProductShowcaseSection client:load />
|
||||
</BaseLayout>
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ContentSection from '../../components/common/ContentSection.vue'
|
||||
import HeroSection from '../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Desktop Privacy Policy — Comfy"
|
||||
description="Privacy policy for Comfy Desktop. Named processors, lawful basis under GDPR/UK GDPR, retention periods, and your rights."
|
||||
>
|
||||
<HeroSection title="Desktop Privacy Policy" />
|
||||
<ContentSection prefix="desktop_privacy" client:load />
|
||||
</BaseLayout>
|
||||
23
apps/website/src/pages/zh-CN/models.astro
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
|
||||
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
|
||||
import AIModelsSection from '../../components/product/shared/AIModelsSection.vue'
|
||||
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="模型 — Comfy"
|
||||
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
|
||||
>
|
||||
<ModelsHeroSection
|
||||
locale="zh-CN"
|
||||
modelName="Grok Imagine"
|
||||
ctaHref="/zh-CN/p/supported-models/grok-imagine"
|
||||
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
|
||||
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
|
||||
/>
|
||||
<ModelCreationsSection client:load locale="zh-CN" />
|
||||
<AIModelsSection client:load locale="zh-CN" />
|
||||
<ProductShowcaseSection client:load locale="zh-CN" />
|
||||
</BaseLayout>
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import ContentSection from '../../../components/common/ContentSection.vue'
|
||||
import HeroSection from '../../../components/legal/HeroSection.vue'
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title="Desktop 隐私政策 — Comfy"
|
||||
description="Comfy Desktop 隐私政策。命名的数据处理方、GDPR/UK GDPR 下的合法依据、保留期限和您的权利。"
|
||||
>
|
||||
<HeroSection title="Desktop 隐私政策" />
|
||||
<ContentSection prefix="desktop_privacy" locale="zh-CN" client:load />
|
||||
</BaseLayout>
|
||||
@@ -1,115 +0,0 @@
|
||||
{
|
||||
"id": "test-missing-model-promoted-widget",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-promoted-missing-model",
|
||||
"pos": [450, 250],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-promoted-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 1,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Promoted Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "ckpt-name-input-id",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "ckpt_name",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -100,7 +100,10 @@ export class ComfyMouse implements Omit<Mouse, 'move'> {
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
async dragElementBy(element: Locator, { x, y }: { x?: number; y?: number }) {
|
||||
async resizeByDragging(
|
||||
element: Locator,
|
||||
{ x, y }: { x?: number; y?: number }
|
||||
) {
|
||||
const elementBox = await element.boundingBox()
|
||||
if (!elementBox) throw new Error('element should have layout')
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
|
||||
import type { CanvasRect } from '@/base/common/selectionBounds'
|
||||
@@ -91,21 +91,3 @@ export async function measureSelectionBounds(
|
||||
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
|
||||
) as Promise<MeasureResult>
|
||||
}
|
||||
|
||||
export async function intersection(a: Locator, b: Locator) {
|
||||
const aBounds = await a.boundingBox()
|
||||
const bBounds = await b.boundingBox()
|
||||
if (!aBounds || !bBounds) return undefined
|
||||
|
||||
const y = Math.max(aBounds.y, bBounds.y)
|
||||
const x = Math.max(aBounds.x, bBounds.x)
|
||||
const bot = Math.min(aBounds.y + aBounds.height, bBounds.y + bBounds.height)
|
||||
const right = Math.min(aBounds.x + aBounds.width, bBounds.x + bBounds.width)
|
||||
|
||||
if (y > bot || x > right) return undefined
|
||||
|
||||
const width = right - x
|
||||
const height = bot - y
|
||||
|
||||
return { x, y, width, height }
|
||||
}
|
||||
|
||||
@@ -545,54 +545,4 @@ test.describe('Keybinding Panel', { tag: '@keyboard' }, () => {
|
||||
await expect(expansionContent).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Responsive Layout', () => {
|
||||
test('Action buttons stay on screen without horizontal scroll at narrow widths', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 480, height: 800 })
|
||||
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Delete/i })
|
||||
).toBeInViewport()
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Add new keybinding/i })
|
||||
).toBeInViewport()
|
||||
|
||||
const hasHorizontalScroll = await page
|
||||
.locator('.keybinding-panel .p-datatable-table-container')
|
||||
.evaluate((el) => el.scrollWidth > el.clientWidth + 1)
|
||||
expect(hasHorizontalScroll).toBe(false)
|
||||
})
|
||||
|
||||
test('Keybinding column compresses with width while actions stay reachable', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { page } = comfyPage
|
||||
|
||||
await searchKeybindings(page, MULTI_BINDING_COMMAND)
|
||||
const row = getCommandRow(page, MULTI_BINDING_COMMAND)
|
||||
const keybindingList = row.getByTestId('keybinding-list')
|
||||
await expect(keybindingList).toBeVisible()
|
||||
|
||||
const listWidthAt = async (viewportWidth: number) => {
|
||||
await page.setViewportSize({ width: viewportWidth, height: 800 })
|
||||
return keybindingList.evaluate((el) => el.getBoundingClientRect().width)
|
||||
}
|
||||
|
||||
const wideWidth = await listWidthAt(1280)
|
||||
const narrowWidth = await listWidthAt(560)
|
||||
|
||||
expect(narrowWidth).toBeLessThan(wideWidth)
|
||||
await expect(
|
||||
row.getByRole('button', { name: /Delete/i })
|
||||
).toBeInViewport()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
|
||||
type Load3dImageInput = {
|
||||
image: string
|
||||
mask: string
|
||||
normal: string
|
||||
recording: string
|
||||
}
|
||||
|
||||
type PromptBody = {
|
||||
prompt?: Record<
|
||||
string,
|
||||
{ class_type?: string; inputs?: Record<string, unknown> }
|
||||
>
|
||||
}
|
||||
|
||||
function getLoad3dImageInput(body: unknown, nodeId: string): Load3dImageInput {
|
||||
const prompt = (body as PromptBody).prompt ?? {}
|
||||
const node = prompt[nodeId]
|
||||
expect(node?.class_type, `node ${nodeId} should be Load3D`).toBe('Load3D')
|
||||
const input = node!.inputs!.image as Load3dImageInput
|
||||
expect(typeof input.image).toBe('string')
|
||||
expect(typeof input.recording).toBe('string')
|
||||
return input
|
||||
}
|
||||
|
||||
test.describe('Load3D serialize cache', () => {
|
||||
test('starting a recording forces the next queue to re-capture (FE-905)', async ({
|
||||
comfyPage,
|
||||
load3d
|
||||
}) => {
|
||||
const exec = new ExecutionHelper(comfyPage)
|
||||
|
||||
let firstBody: unknown
|
||||
await exec.run({
|
||||
onPromptRequest: (body) => {
|
||||
firstBody = body
|
||||
}
|
||||
})
|
||||
const firstInput = getLoad3dImageInput(firstBody, '1')
|
||||
expect(firstInput.recording).toBe('')
|
||||
|
||||
await load3d.recordingButton.click()
|
||||
await expect(load3d.stopRecordingButton).toBeVisible()
|
||||
|
||||
let secondBody: unknown
|
||||
await exec.run({
|
||||
onPromptRequest: (body) => {
|
||||
secondBody = body
|
||||
}
|
||||
})
|
||||
const secondInput = getLoad3dImageInput(secondBody, '1')
|
||||
|
||||
expect(
|
||||
secondInput.image,
|
||||
'after starting a recording, the next queue must re-capture ' +
|
||||
'(image filename must change) so the recording is not dropped'
|
||||
).not.toBe(firstInput.image)
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('Should keep execution errors matching the search query', async ({
|
||||
test('Should filter execution errors by search query', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
|
||||
@@ -62,9 +62,9 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
|
||||
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
|
||||
await searchInput.fill('Execution failed')
|
||||
await searchInput.fill('nonexistent_query_xyz_12345')
|
||||
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
await expect(runtimePanel).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,7 +41,7 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show runtime error log in the execution error group', async ({
|
||||
test('Should show error message in runtime error panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openExecutionErrorTab(comfyPage)
|
||||
@@ -50,6 +50,6 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
|
||||
TestIds.dialogs.runtimeErrorPanel
|
||||
)
|
||||
await expect(runtimePanel).toBeVisible()
|
||||
await expect(runtimePanel).toContainText('Error log')
|
||||
await expect(runtimePanel).toContainText(/\S/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -369,62 +369,6 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test(
|
||||
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
|
||||
{ tag: ['@canvas', '@widget', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_model_promoted_widget'
|
||||
)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate((value) => {
|
||||
const hostNode = window.app!.graph!.getNodeById(2)
|
||||
if (!hostNode?.isSubgraphNode()) {
|
||||
throw new Error('Expected subgraph host node')
|
||||
}
|
||||
|
||||
const interiorNode = hostNode.subgraph.getNodeById(1)
|
||||
const widget = interiorNode?.widgets?.find(
|
||||
(entry) => entry.name === 'ckpt_name'
|
||||
)
|
||||
type SettableWidget = typeof widget & {
|
||||
setValue?: (
|
||||
value: string,
|
||||
options: {
|
||||
e: PointerEvent
|
||||
node: unknown
|
||||
canvas: unknown
|
||||
}
|
||||
) => void
|
||||
}
|
||||
const settableWidget = widget as SettableWidget | undefined
|
||||
|
||||
if (!settableWidget?.setValue) {
|
||||
throw new Error('Expected concrete ckpt_name widget')
|
||||
}
|
||||
|
||||
settableWidget.setValue(value, {
|
||||
e: new PointerEvent('pointerup'),
|
||||
node: hostNode,
|
||||
canvas: window.app!.canvas
|
||||
})
|
||||
}, resolvedModelName)
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
}
|
||||
)
|
||||
|
||||
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
@@ -1,30 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Textarea widget font size',
|
||||
{ tag: ['@widget', '@vue-nodes'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
})
|
||||
|
||||
test('applies Comfy.TextareaWidget.FontSize to Vue Nodes 2.0 textarea widget', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const textarea = comfyPage.vueNodes.nodes.locator('textarea').first()
|
||||
await expect(textarea).toBeVisible()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 14)
|
||||
await expect
|
||||
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
|
||||
.toBe('14px')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 22)
|
||||
await expect
|
||||
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
|
||||
.toBe('22px')
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { getMiddlePoint } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { fitToViewInstant } from '@e2e/fixtures/utils/fitToView'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
|
||||
const box = await locator.boundingBox()
|
||||
@@ -1232,41 +1231,3 @@ test.describe('Vue Node Widget Link Position', { tag: '@vue-nodes' }, () => {
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'Fast disconnection support',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyMouse, comfyPage }) => {
|
||||
async function performDisconnect(slot: Locator, isFast: boolean) {
|
||||
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
|
||||
|
||||
if (!isFast) {
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
|
||||
await comfyMouse.click(100, 100)
|
||||
}
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(slot)
|
||||
await expect.poll(isConnected).toBe(false)
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
|
||||
}
|
||||
|
||||
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const ksampler = new VueNodeFixture(ksamplerLocator)
|
||||
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
|
||||
|
||||
await test.step('Disconnection with normal links', async () => {
|
||||
await performDisconnect(ksampler.getSlot('model'), true)
|
||||
await performDisconnect(ksampler.getSlot('positive'), false)
|
||||
})
|
||||
|
||||
await test.step('Create subgraph', async () => {
|
||||
await ksampler.title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
})
|
||||
|
||||
await test.step('Disconnection with subgraph IO', async () => {
|
||||
await performDisconnect(ksampler.getSlot('negative'), true)
|
||||
await performDisconnect(ksampler.getSlot('latent_image'), false)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -174,50 +174,10 @@ test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
const { bottomRight } = node.resize
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBe(10)
|
||||
await comfyMouse.dragElementBy(bottomRight, { x: 200 })
|
||||
await comfyMouse.resizeByDragging(bottomRight, { x: 200 })
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBeGreaterThan(10)
|
||||
await comfyMouse.dragElementBy(bottomRight, { x: -200, y: 200 })
|
||||
await comfyMouse.resizeByDragging(bottomRight, { x: -200, y: 200 })
|
||||
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
|
||||
}
|
||||
)
|
||||
|
||||
wstest(
|
||||
'requests lightweight thumbnail URLs for grid cells',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
await test.step('Add node', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
|
||||
await expect(previewImage).toBeVisible()
|
||||
})
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
const gridImages = node.imageGrid.locator('img')
|
||||
|
||||
await test.step('Inject a multi-image grid', async () => {
|
||||
const images = Array.from({ length: 4 }, (_, index) => ({
|
||||
filename: `grid-${index}.png`,
|
||||
subfolder: '',
|
||||
type: 'output'
|
||||
}))
|
||||
execution.executed('', '1', { images })
|
||||
await expect(gridImages).toHaveCount(4)
|
||||
})
|
||||
|
||||
// FE-741: small on-node grid cells must request a server re-encoded
|
||||
// thumbnail (`preview=webp;75`, `;` may be percent-encoded) instead of
|
||||
// downloading the full-resolution image, while still pointing at the
|
||||
// real `/api/view` URL for that output. Verifies the full path: WS
|
||||
// output -> nodeOutputStore.buildImageUrls -> getGridThumbnailUrl ->
|
||||
// rendered grid `<img>`.
|
||||
for (const cell of await gridImages.all()) {
|
||||
await expect(cell).toHaveAttribute('src', /[?&]preview=webp(%3B|;)75/)
|
||||
await expect(cell).toHaveAttribute('src', /[?&]filename=grid-\d+\.png/)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
@@ -1,12 +1,10 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { intersection } from '@e2e/fixtures/utils/boundsUtils'
|
||||
import type { Locator } from '@playwright/test'
|
||||
|
||||
test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
|
||||
async function openSamplerDropdown(comfyPage: ComfyPage) {
|
||||
@@ -280,31 +278,4 @@ test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
|
||||
.getByRole('combobox', { name: 'scheduler', exact: true })
|
||||
await expect(schedulerComboAfterReload).toContainText('karras')
|
||||
})
|
||||
|
||||
test('Dropdown displays over Selection Toolbox', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
const nodeName = 'Resize Image/Mask'
|
||||
await comfyPage.searchBoxV2.addNode(nodeName, {
|
||||
position: { x: 200, y: 630 }
|
||||
})
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle(nodeName)
|
||||
await node.select()
|
||||
await expect(comfyPage.selectionToolbox).toBeVisible()
|
||||
|
||||
const combo = comfyPage.vueNodes.getWidgetByName(nodeName, 'resize_type')
|
||||
await combo.click()
|
||||
const dropdown = comfyPage.page.getByTestId(
|
||||
TestIds.widgets.selectDefaultViewport
|
||||
)
|
||||
await expect(dropdown).toBeVisible()
|
||||
|
||||
const bounds = (await intersection(dropdown, comfyPage.selectionToolbox))!
|
||||
expect(bounds, 'toolbox and dropdown overlap').toBeDefined()
|
||||
const cX = bounds.x + bounds.width / 2
|
||||
const cY = bounds.y + bounds.height / 2
|
||||
const dropdownBounds = (await dropdown.boundingBox())!
|
||||
const position = { x: cX - dropdownBounds.x, y: cY - dropdownBounds.y }
|
||||
await dropdown.click({ position, trial: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,11 +40,11 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
|
||||
const gutter = comfyPage.page.getByRole('separator')
|
||||
|
||||
await expect(gutter).toBeVisible()
|
||||
await comfyMouse.dragElementBy(gutter, { x: -200 })
|
||||
await comfyMouse.resizeByDragging(gutter, { x: -200 })
|
||||
await expect.poll(getWidth).toBeGreaterThan(initialWidth)
|
||||
const intermediateWidth = await getWidth()
|
||||
|
||||
await comfyMouse.dragElementBy(gutter, { x: 100 })
|
||||
await comfyMouse.resizeByDragging(gutter, { x: 100 })
|
||||
await expect.poll(getWidth).toBeLessThan(intermediateWidth)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.46.11",
|
||||
"version": "1.46.9",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -121,7 +121,6 @@
|
||||
--comfy-topbar-height: 2.5rem;
|
||||
--workflow-tabs-height: 2.375rem;
|
||||
--comfy-input-bg: #222;
|
||||
--comfy-textarea-font-size: 10px;
|
||||
--input-text: #ddd;
|
||||
--descrip-text: #999;
|
||||
--drag-text: #ccc;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import Popover from '@/components/ui/Popover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -49,6 +50,7 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
|
||||
)
|
||||
|
||||
provide(HideLayoutFieldKey, true)
|
||||
provide(OverlayAppendToKey, 'body')
|
||||
|
||||
const resolvedInputs = useResolvedSelectedInputs()
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
:ref="primeVueOverlay.overlayScopeRef"
|
||||
class="keybinding-panel flex min-w-0 flex-col gap-2 overflow-x-hidden"
|
||||
class="keybinding-panel flex flex-col gap-2"
|
||||
>
|
||||
<Teleport defer to="#keybinding-panel-header">
|
||||
<SearchInput
|
||||
@@ -46,10 +46,7 @@
|
||||
|
||||
<ContextMenuRoot>
|
||||
<ContextMenuTrigger as-child>
|
||||
<div
|
||||
class="min-w-0 overflow-x-hidden"
|
||||
@contextmenu.capture="clearContextMenuTarget"
|
||||
>
|
||||
<div @contextmenu.capture="clearContextMenuTarget">
|
||||
<DataTable
|
||||
v-model:selection="selectedCommandData"
|
||||
v-model:expanded-rows="expandedRows"
|
||||
@@ -63,7 +60,6 @@
|
||||
selection-mode="single"
|
||||
context-menu
|
||||
striped-rows
|
||||
:table-style="{ tableLayout: 'fixed', width: '100%' }"
|
||||
:pt="{
|
||||
header: 'px-0'
|
||||
}"
|
||||
@@ -75,11 +71,12 @@
|
||||
field="id"
|
||||
:header="$t('g.command')"
|
||||
sortable
|
||||
class="max-w-64 2xl:max-w-full"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-1 truncate"
|
||||
class="flex items-center gap-1 truncate"
|
||||
:class="slotProps.data.keybindings.length < 2 && 'pl-5'"
|
||||
:title="slotProps.data.id"
|
||||
>
|
||||
@@ -106,38 +103,53 @@
|
||||
<Column
|
||||
field="keybindings"
|
||||
:header="$t('g.keybinding')"
|
||||
:style="{ width: '30%' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<KeybindingList
|
||||
:keybindings="slotProps.data.keybindings"
|
||||
:is-modified="slotProps.data.isModified"
|
||||
/>
|
||||
<div
|
||||
v-if="slotProps.data.keybindings.length > 0"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<template
|
||||
v-for="(binding, idx) in (
|
||||
slotProps.data as ICommandData
|
||||
).keybindings.slice(0, 2)"
|
||||
:key="binding.combo.serialize()"
|
||||
>
|
||||
<span v-if="idx > 0" class="text-muted-foreground">,</span>
|
||||
<KeyComboDisplay
|
||||
:key-combo="binding.combo"
|
||||
:is-modified="slotProps.data.isModified"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="slotProps.data.keybindings.length > 2"
|
||||
class="rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{{
|
||||
$t('g.nMoreKeybindings', {
|
||||
count: slotProps.data.keybindings.length - 2
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="source"
|
||||
:header="$t('g.source')"
|
||||
:style="{ width: '16%' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8' }"
|
||||
>
|
||||
<template #body="slotProps">
|
||||
<span class="block truncate" :title="slotProps.data.source">{{
|
||||
<span class="overflow-hidden text-ellipsis">{{
|
||||
slotProps.data.source || '-'
|
||||
}}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column
|
||||
field="actions"
|
||||
header=""
|
||||
:style="{ width: '9rem' }"
|
||||
:pt="{ bodyCell: 'p-1 min-h-8 whitespace-nowrap' }"
|
||||
>
|
||||
<Column field="actions" header="" :pt="{ bodyCell: 'p-1 min-h-8' }">
|
||||
<template #body="slotProps">
|
||||
<div
|
||||
class="actions flex flex-row justify-end whitespace-nowrap"
|
||||
>
|
||||
<div class="actions flex flex-row justify-end">
|
||||
<Button
|
||||
v-if="slotProps.data.keybindings.length === 1"
|
||||
v-tooltip="$t('g.edit')"
|
||||
@@ -318,7 +330,6 @@ import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import KeybindingList from './keybinding/KeybindingList.vue'
|
||||
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
|
||||
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
import KeybindingList from './KeybindingList.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: {
|
||||
nMoreKeybindings: '+ {count} more',
|
||||
nMoreKeybindingsCompact: '+ {count}',
|
||||
keybindingListAriaLabel: 'Keybindings: {combos}'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeKeybinding(key: string, ctrl = false, shift = false) {
|
||||
return new KeybindingImpl({
|
||||
commandId: 'test.cmd',
|
||||
combo: { key, ctrl, shift }
|
||||
})
|
||||
}
|
||||
|
||||
function renderList(props: {
|
||||
keybindings: KeybindingImpl[]
|
||||
isModified?: boolean
|
||||
}) {
|
||||
return render(KeybindingList, {
|
||||
props,
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
describe('KeybindingList', () => {
|
||||
it('renders "-" placeholder when there are no keybindings', () => {
|
||||
renderList({ keybindings: [] })
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('keybinding-list')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a single keybinding without any "more" badge', () => {
|
||||
renderList({ keybindings: [makeKeybinding('A', true)] })
|
||||
expect(screen.getByTestId('keybinding-list')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-wide')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-medium')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-compact')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('with 2 keybindings: omits wide-tier badge, shows medium/compact for narrow widths', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('keybinding-list-more-wide')
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
|
||||
'+ 1 more'
|
||||
)
|
||||
expect(
|
||||
screen.getByTestId('keybinding-list-more-compact')
|
||||
).toHaveTextContent('+ 1')
|
||||
})
|
||||
|
||||
it('with 3 keybindings: wide-tier uses count-minus-two, narrower tiers use count-minus-one', () => {
|
||||
renderList({
|
||||
keybindings: [
|
||||
makeKeybinding('A', true),
|
||||
makeKeybinding('B', true),
|
||||
makeKeybinding('C', true)
|
||||
]
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('keybinding-list-more-wide')).toHaveTextContent(
|
||||
'+ 1 more'
|
||||
)
|
||||
expect(screen.getByTestId('keybinding-list-more-medium')).toHaveTextContent(
|
||||
'+ 2 more'
|
||||
)
|
||||
expect(
|
||||
screen.getByTestId('keybinding-list-more-compact')
|
||||
).toHaveTextContent('+ 2')
|
||||
})
|
||||
|
||||
it('uses a container query parent so the visible tier can adapt to width', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true)]
|
||||
})
|
||||
expect(screen.getByTestId('keybinding-list').className).toContain(
|
||||
'@container/keybindings'
|
||||
)
|
||||
})
|
||||
|
||||
it('emits an accessible label listing all combos', () => {
|
||||
renderList({
|
||||
keybindings: [makeKeybinding('A', true), makeKeybinding('B', true, true)]
|
||||
})
|
||||
const ariaText = screen.getByTestId('keybinding-list-aria').textContent
|
||||
expect(ariaText).toContain('Keybindings:')
|
||||
expect(ariaText).toContain('Ctrl')
|
||||
expect(ariaText).toContain('A')
|
||||
expect(ariaText).toContain('Shift')
|
||||
expect(ariaText).toContain('B')
|
||||
})
|
||||
})
|
||||
@@ -1,74 +0,0 @@
|
||||
<template>
|
||||
<span
|
||||
v-if="keybindings.length > 0"
|
||||
class="@container/keybindings flex w-full min-w-0 items-center gap-1 overflow-hidden"
|
||||
data-testid="keybinding-list"
|
||||
>
|
||||
<KeyComboDisplay
|
||||
:key-combo="keybindings[0].combo"
|
||||
:is-modified="isModified"
|
||||
/>
|
||||
<template v-if="keybindings.length >= 2">
|
||||
<span
|
||||
class="hidden text-muted-foreground @[16rem]/keybindings:inline"
|
||||
aria-hidden="true"
|
||||
>
|
||||
,
|
||||
</span>
|
||||
<KeyComboDisplay
|
||||
class="hidden @[16rem]/keybindings:inline-flex"
|
||||
:key-combo="keybindings[1].combo"
|
||||
:is-modified="isModified"
|
||||
/>
|
||||
</template>
|
||||
<span
|
||||
v-if="keybindings.length > 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[16rem]/keybindings:inline"
|
||||
data-testid="keybinding-list-more-wide"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 2 }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="keybindings.length >= 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[12rem]/keybindings:inline @[16rem]/keybindings:hidden"
|
||||
data-testid="keybinding-list-more-medium"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindings', { count: keybindings.length - 1 }) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="keybindings.length >= 2"
|
||||
class="hidden rounded-sm px-1.5 py-0.5 text-xs text-muted-foreground @[8rem]/keybindings:inline @[12rem]/keybindings:hidden"
|
||||
data-testid="keybinding-list-more-compact"
|
||||
>
|
||||
{{ $t('g.nMoreKeybindingsCompact', { count: keybindings.length - 1 }) }}
|
||||
</span>
|
||||
<span class="sr-only" data-testid="keybinding-list-aria">
|
||||
{{ ariaLabel }}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { KeybindingImpl } from '@/platform/keybindings/keybinding'
|
||||
|
||||
import KeyComboDisplay from './KeyComboDisplay.vue'
|
||||
|
||||
const { keybindings, isModified = false } = defineProps<{
|
||||
keybindings: KeybindingImpl[]
|
||||
isModified?: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const ariaLabel = computed(() => {
|
||||
if (keybindings.length === 0) return ''
|
||||
const combos = keybindings
|
||||
.map((binding) => binding.combo.toString())
|
||||
.join(', ')
|
||||
return t('g.keybindingListAriaLabel', { combos })
|
||||
})
|
||||
</script>
|
||||
@@ -20,7 +20,6 @@
|
||||
<InfoButton v-if="canOpenNodeInfo" />
|
||||
|
||||
<ColorPickerButton v-if="showColorPicker" />
|
||||
<ArrangeButton v-if="showArrange" />
|
||||
<FrameNodes v-if="showFrameNodes" />
|
||||
<ConvertToSubgraphButton v-if="showConvertToSubgraph" />
|
||||
<ConfigureSubgraph v-if="showSubgraphButtons" />
|
||||
@@ -50,7 +49,6 @@
|
||||
import Panel from 'primevue/panel'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ArrangeButton from '@/components/graph/selectionToolbox/ArrangeButton.vue'
|
||||
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
|
||||
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
|
||||
import ConfigureSubgraph from '@/components/graph/selectionToolbox/ConfigureSubgraph.vue'
|
||||
@@ -112,7 +110,6 @@ const {
|
||||
|
||||
const showColorPicker = computed(() => hasAnySelection.value)
|
||||
const showConvertToSubgraph = computed(() => hasAnySelection.value)
|
||||
const showArrange = computed(() => hasMultipleSelection.value)
|
||||
const showFrameNodes = computed(() => hasMultipleSelection.value)
|
||||
const showSubgraphButtons = computed(() => isSingleSubgraph.value)
|
||||
|
||||
@@ -131,7 +128,6 @@ const showAnyPrimaryActions = computed(
|
||||
() =>
|
||||
showColorPicker.value ||
|
||||
showConvertToSubgraph.value ||
|
||||
showArrange.value ||
|
||||
showFrameNodes.value ||
|
||||
showSubgraphButtons.value
|
||||
)
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button
|
||||
v-tooltip.top="{ value: t('g.arrange'), showDelay: 1000 }"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrange')"
|
||||
>
|
||||
<div class="flex items-center gap-1 px-0">
|
||||
<i class="icon-[lucide--layout-grid]" />
|
||||
<i class="icon-[lucide--chevron-down]" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
:side-offset="5"
|
||||
:collision-padding="10"
|
||||
class="data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
|
||||
>
|
||||
<div
|
||||
v-if="activeLayout"
|
||||
class="flex w-32 flex-row items-center px-2 py-1"
|
||||
>
|
||||
<Slider
|
||||
:model-value="[gap]"
|
||||
:min="MIN_ARRANGE_GAP"
|
||||
:max="MAX_ARRANGE_GAP"
|
||||
:step="1"
|
||||
:aria-label="t('g.arrangeSpacing')"
|
||||
@update:model-value="onSliderUpdate"
|
||||
@value-commit="onSliderCommit"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex flex-row gap-1">
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: t('g.arrangeVertically'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrangeVertically')"
|
||||
@click="start('vertical')"
|
||||
>
|
||||
<i class="icon-[lucide--stretch-horizontal]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="{
|
||||
value: t('g.arrangeHorizontally'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrangeHorizontally')"
|
||||
@click="start('horizontal')"
|
||||
>
|
||||
<i class="icon-[lucide--stretch-vertical]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="{ value: t('g.arrangeAsGrid'), showDelay: 1000 }"
|
||||
variant="muted-textonly"
|
||||
:aria-label="t('g.arrangeAsGrid')"
|
||||
@click="start('grid')"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3]" />
|
||||
</Button>
|
||||
</div>
|
||||
<PopoverArrow class="fill-base-background stroke-border-subtle" />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PopoverArrow,
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
PopoverRoot,
|
||||
PopoverTrigger
|
||||
} from 'reka-ui'
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import {
|
||||
MAX_ARRANGE_GAP,
|
||||
MIN_ARRANGE_GAP
|
||||
} from '@/composables/graph/useArrangeNodes'
|
||||
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { activeLayout, gap, start, previewGap, commitGap, reset } =
|
||||
useArrangeSession()
|
||||
|
||||
const isOpen = ref(false)
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) reset()
|
||||
})
|
||||
|
||||
const firstValue = (value: number[] | undefined): number | undefined =>
|
||||
value?.[0]
|
||||
|
||||
const onSliderUpdate = (value: number[] | undefined) => {
|
||||
const next = firstValue(value)
|
||||
if (next !== undefined) previewGap(next)
|
||||
}
|
||||
|
||||
const onSliderCommit = (value: number[]) => {
|
||||
const next = firstValue(value)
|
||||
if (next !== undefined) commitGap(next)
|
||||
}
|
||||
</script>
|
||||
@@ -2,12 +2,20 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
|
||||
/**
|
||||
* ErrorNodeCard displays a single error card inside the error tab.
|
||||
* It shows the node header (ID badge, title, action buttons)
|
||||
* and the list of error items (message, traceback, copy button).
|
||||
*/
|
||||
const meta: Meta<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
component: ErrorNodeCard,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
argTypes: {
|
||||
showNodeIdBadge: { control: 'boolean' }
|
||||
},
|
||||
decorators: [
|
||||
(story) => ({
|
||||
components: { story },
|
||||
@@ -97,36 +105,58 @@ const promptOnlyCard: ErrorCardData = {
|
||||
]
|
||||
}
|
||||
|
||||
export const SingleValidationError: Story = {
|
||||
/** Single validation error with node ID badge visible */
|
||||
export const WithNodeIdBadge: Story = {
|
||||
args: {
|
||||
card: singleErrorCard
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Single validation error without node ID badge */
|
||||
export const WithoutNodeIdBadge: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: false
|
||||
}
|
||||
}
|
||||
|
||||
/** Subgraph node error — shows "Enter subgraph" button */
|
||||
export const WithEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: subgraphErrorCard
|
||||
card: subgraphErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Regular node error — no "Enter subgraph" button */
|
||||
export const WithoutEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: singleErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Multiple validation errors on one node */
|
||||
export const MultipleErrors: Story = {
|
||||
args: {
|
||||
card: multipleErrorsCard
|
||||
card: multipleErrorsCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Runtime execution error with full traceback */
|
||||
export const RuntimeError: Story = {
|
||||
args: {
|
||||
card: runtimeErrorCard
|
||||
card: runtimeErrorCard,
|
||||
showNodeIdBadge: true
|
||||
}
|
||||
}
|
||||
|
||||
/** Prompt-level error (no node header) */
|
||||
export const PromptError: Story = {
|
||||
args: {
|
||||
card: promptOnlyCard
|
||||
card: promptOnlyCard,
|
||||
showNodeIdBadge: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,6 @@ describe('ErrorNodeCard.vue', () => {
|
||||
en: {
|
||||
g: {
|
||||
copy: 'Copy',
|
||||
details: 'Details',
|
||||
findIssues: 'Find Issues',
|
||||
findOnGithub: 'Find on GitHub',
|
||||
getHelpAction: 'Get Help'
|
||||
@@ -79,7 +78,6 @@ describe('ErrorNodeCard.vue', () => {
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate Node',
|
||||
enterSubgraph: 'Enter Subgraph',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues for related problems',
|
||||
getHelpTooltip:
|
||||
'Report this error and we\u0027ll help you resolve it'
|
||||
@@ -98,9 +96,8 @@ describe('ErrorNodeCard.vue', () => {
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
const onCopyToClipboard = vi.fn()
|
||||
const onLocateNode = vi.fn()
|
||||
render(ErrorNodeCard, {
|
||||
props: { card, onCopyToClipboard, onLocateNode },
|
||||
props: { card, onCopyToClipboard },
|
||||
global: {
|
||||
plugins: [
|
||||
PrimeVue,
|
||||
@@ -134,20 +131,14 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
TransitionCollapse: { template: '<div><slot /></div>' },
|
||||
Button: {
|
||||
template: '<button v-bind="$attrs"><slot /></button>'
|
||||
template:
|
||||
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user, onCopyToClipboard, onLocateNode }
|
||||
}
|
||||
|
||||
async function toggleRuntimeDetails(
|
||||
user: ReturnType<typeof userEvent.setup>
|
||||
) {
|
||||
await user.click(screen.getByRole('button', { name: /Details/ }))
|
||||
return { user, onCopyToClipboard }
|
||||
}
|
||||
|
||||
let cardIdCounter = 0
|
||||
@@ -169,67 +160,40 @@ describe('ErrorNodeCard.vue', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function makePromptErrorCard(): ErrorCardData {
|
||||
function makeValidationErrorCard(): ErrorCardData {
|
||||
return {
|
||||
id: '__prompt__',
|
||||
title: 'Prompt has no outputs',
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: '6',
|
||||
nodeTitle: 'CLIP Text Encode',
|
||||
errors: [
|
||||
{
|
||||
message: 'Server Error: No outputs',
|
||||
details: 'Error details',
|
||||
displayMessage:
|
||||
'The workflow does not contain any output nodes to produce a result.'
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: text'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
it('shows runtime details by default and can collapse them', async () => {
|
||||
it('displays enriched report for runtime errors on mount', async () => {
|
||||
const reportText =
|
||||
'# ComfyUI Error Report\n## System Information\n- OS: Linux'
|
||||
mockGenerateErrorReport.mockReturnValue(reportText)
|
||||
|
||||
const { user } = renderCard(makeRuntimeErrorCard())
|
||||
renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByRole('listitem')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Error log')).toBeInTheDocument()
|
||||
const detailsButton = screen.getByRole('button', { name: /Details/ })
|
||||
const detailsRegion = screen.getByRole('region', { name: 'Error log' })
|
||||
expect(detailsButton).toHaveAttribute(
|
||||
'aria-controls',
|
||||
detailsRegion.getAttribute('id')
|
||||
)
|
||||
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/System Information/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/OS: Linux/)).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Find on GitHub/ })
|
||||
).toBeInTheDocument()
|
||||
|
||||
await toggleRuntimeDetails(user)
|
||||
|
||||
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Find on GitHub/ })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('locates the node when the runtime node title is clicked', async () => {
|
||||
const { user, onLocateNode } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'KSampler' }))
|
||||
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
it('does not generate report for non-runtime errors', async () => {
|
||||
renderCard(makePromptErrorCard())
|
||||
renderCard(makeValidationErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Error details')).toBeInTheDocument()
|
||||
expect(screen.getByText('Input: text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockGetLogs).not.toHaveBeenCalled()
|
||||
@@ -237,15 +201,15 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
|
||||
it('displays original details for non-runtime errors', async () => {
|
||||
renderCard(makePromptErrorCard())
|
||||
renderCard(makeValidationErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Error details')).toBeInTheDocument()
|
||||
expect(screen.getByText('Input: text')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides grouped catalog copy and shows the item label as a list item', async () => {
|
||||
it('displays catalog-resolved copy when available', async () => {
|
||||
renderCard({
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
@@ -265,17 +229,17 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('KSampler - model')).toBeInTheDocument()
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('listitem')).toHaveTextContent('KSampler - model')
|
||||
expect(screen.queryByText('Missing connection')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
)
|
||||
).not.toBeInTheDocument()
|
||||
screen.getByText('Required input slots have no connection feeding them.')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('KSampler is missing a required input: model')
|
||||
screen.getByText('KSampler is missing a required input: model')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('KSampler - model')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByText('Required input is missing')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -286,9 +250,8 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const { user, onCopyToClipboard } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Copy/ }))
|
||||
|
||||
@@ -298,6 +261,21 @@ describe('ErrorNodeCard.vue', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('copies original details when copy button is clicked for validation error', async () => {
|
||||
const { user, onCopyToClipboard } = renderCard(makeValidationErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Input: text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Copy/ }))
|
||||
|
||||
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(onCopyToClipboard.mock.calls[0][0]).toBe(
|
||||
'Required input is missing\n\nInput: text'
|
||||
)
|
||||
})
|
||||
|
||||
it('generates report with fallback logs when getLogs fails', async () => {
|
||||
mockGetLogs.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
@@ -322,9 +300,8 @@ describe('ErrorNodeCard.vue', () => {
|
||||
renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens GitHub issues search when Find Issue button is clicked', async () => {
|
||||
@@ -333,7 +310,9 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const { user } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Find on GitHub/ })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
|
||||
@@ -356,7 +335,9 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const { user } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Get Help/ })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Get Help/ }))
|
||||
@@ -417,7 +398,9 @@ describe('ErrorNodeCard.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<!-- Card Header -->
|
||||
<div
|
||||
v-if="card.nodeId && !compact"
|
||||
class="flex flex-wrap items-center gap-2 py-2"
|
||||
>
|
||||
<button
|
||||
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
|
||||
type="button"
|
||||
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
</button>
|
||||
<span
|
||||
v-else-if="card.nodeTitle || card.title"
|
||||
v-if="showNodeIdBadge"
|
||||
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
|
||||
>
|
||||
#{{ card.nodeId }}
|
||||
</span>
|
||||
<span
|
||||
v-if="card.nodeTitle || card.title"
|
||||
class="flex-1 truncate text-sm font-medium text-muted-foreground"
|
||||
>
|
||||
{{ card.nodeTitle || card.title }}
|
||||
@@ -28,24 +27,6 @@
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasRuntimeError"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
runtimeDetailsExpanded &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="t('g.details')"
|
||||
:aria-controls="runtimeDetailsControlIds || undefined"
|
||||
:aria-expanded="runtimeDetailsExpanded"
|
||||
@click.stop="toggleRuntimeDetails"
|
||||
>
|
||||
<i class="icon-[lucide--monitor-x] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
@@ -58,143 +39,120 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multiple Errors within one Card -->
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
|
||||
>
|
||||
<!-- Card Content -->
|
||||
<div
|
||||
v-for="(error, idx) in card.errors"
|
||||
:key="idx"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-0 flex-col gap-3',
|
||||
fullHeight && error.isRuntimeError && 'flex-1'
|
||||
)
|
||||
"
|
||||
>
|
||||
<!-- Human-friendly category/title when resolved by the error catalog. -->
|
||||
<p
|
||||
v-if="getInlineMessage(error)"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
v-if="error.displayTitle"
|
||||
class="m-0 px-0.5 text-sm font-semibold text-destructive-background-hover"
|
||||
>
|
||||
{{ getInlineMessage(error) }}
|
||||
{{ error.displayTitle }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="getInlineItemLabel(error)"
|
||||
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
|
||||
<!-- Error Message -->
|
||||
<p
|
||||
v-if="getDisplayMessage(error)"
|
||||
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
|
||||
>
|
||||
<li class="min-w-0 wrap-break-word">
|
||||
<button
|
||||
v-if="card.nodeId"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode"
|
||||
>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
</button>
|
||||
<span v-else>
|
||||
{{ getInlineItemLabel(error) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
{{ getDisplayMessage(error) }}
|
||||
</p>
|
||||
|
||||
<!-- Traceback / Details (enriched with full report for runtime errors) -->
|
||||
<div
|
||||
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
|
||||
v-if="displayedDetailsMap[idx]"
|
||||
:class="
|
||||
cn(
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
|
||||
'max-h-[6lh]'
|
||||
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background-hover p-2.5',
|
||||
error.isRuntimeError
|
||||
? fullHeight
|
||||
? 'min-h-0 flex-1'
|
||||
: 'max-h-[15lh]'
|
||||
: 'max-h-[6lh]'
|
||||
)
|
||||
"
|
||||
>
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
{{ displayedDetailsMap[idx] }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TransitionCollapse>
|
||||
<div
|
||||
v-if="error.isRuntimeError && isRuntimeDisclosureExpanded"
|
||||
:id="getRuntimeDetailsId(idx)"
|
||||
role="region"
|
||||
data-testid="runtime-error-panel"
|
||||
:aria-label="t('rightSidePanel.errorLog')"
|
||||
class="flex min-h-0 flex-col gap-3"
|
||||
>
|
||||
<div
|
||||
v-if="getInlineDetails(error, idx)"
|
||||
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
|
||||
>
|
||||
{{ t('rightSidePanel.errorLog') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('g.copy')"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
<i class="icon-[lucide--copy] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
|
||||
<p
|
||||
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ getInlineDetails(error, idx) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
|
||||
<div class="mx-3 flex items-center justify-between gap-2 py-2">
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
{{ t('g.getHelpAction') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
|
||||
variant="textonly"
|
||||
size="sm"
|
||||
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
|
||||
data-testid="error-card-find-on-github"
|
||||
@click="handleCheckGithub(error)"
|
||||
>
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
{{ t('g.findOnGithub') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{{ t('g.findOnGithub') }}
|
||||
<i class="icon-[lucide--github] size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
|
||||
data-testid="error-card-copy"
|
||||
@click="handleCopyError(idx)"
|
||||
>
|
||||
{{ t('g.copy') }}
|
||||
<i class="icon-[lucide--copy] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TransitionCollapse>
|
||||
<Button
|
||||
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="h-8 w-full justify-center gap-1 rounded-lg text-xs"
|
||||
@click="handleGetHelp"
|
||||
>
|
||||
{{ t('g.getHelpAction') }}
|
||||
<i class="icon-[lucide--external-link] size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
|
||||
import type { ErrorCardData, ErrorItem } from './types'
|
||||
import { useErrorActions } from './useErrorActions'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
const { card, compact = false } = defineProps<{
|
||||
const {
|
||||
card,
|
||||
showNodeIdBadge = false,
|
||||
compact = false,
|
||||
fullHeight = false
|
||||
} = defineProps<{
|
||||
card: ErrorCardData
|
||||
showNodeIdBadge?: boolean
|
||||
/** Hide card header and error message (used in single-node selection mode) */
|
||||
compact?: boolean
|
||||
/** Allow runtime error details to fill available height (used in dedicated panel) */
|
||||
fullHeight?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -206,23 +164,6 @@ const emit = defineEmits<{
|
||||
const { t } = useI18n()
|
||||
const { displayedDetailsMap } = useErrorReport(() => card)
|
||||
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
|
||||
const runtimeDetailsExpanded = ref(true)
|
||||
const hasRuntimeError = computed(() =>
|
||||
card.errors.some((error) => error.isRuntimeError)
|
||||
)
|
||||
const isRuntimeDisclosureExpanded = computed(
|
||||
() => compact || runtimeDetailsExpanded.value
|
||||
)
|
||||
const runtimeDetailsControlIds = computed(() =>
|
||||
card.errors
|
||||
.map((error, idx) => (error.isRuntimeError ? getRuntimeDetailsId(idx) : ''))
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
)
|
||||
|
||||
function toggleRuntimeDetails() {
|
||||
runtimeDetailsExpanded.value = !runtimeDetailsExpanded.value
|
||||
}
|
||||
|
||||
function handleLocateNode() {
|
||||
if (card.nodeId) {
|
||||
@@ -238,7 +179,7 @@ function handleEnterSubgraph() {
|
||||
|
||||
function handleCopyError(idx: number) {
|
||||
const details = displayedDetailsMap.value[idx]
|
||||
const message = getCopyMessage(card.errors[idx])
|
||||
const message = getDisplayMessage(card.errors[idx])
|
||||
emit('copyToClipboard', [message, details].filter(Boolean).join('\n\n'))
|
||||
}
|
||||
|
||||
@@ -246,26 +187,7 @@ function handleCheckGithub(error: ErrorItem) {
|
||||
findOnGitHub(error.message)
|
||||
}
|
||||
|
||||
function getCopyMessage(error: ErrorItem | undefined) {
|
||||
function getDisplayMessage(error: ErrorItem | undefined) {
|
||||
return error?.displayMessage ?? error?.message
|
||||
}
|
||||
|
||||
function getInlineMessage(error: ErrorItem | undefined) {
|
||||
if (!error || error.displayMessage) return undefined
|
||||
return error.message
|
||||
}
|
||||
|
||||
function getInlineItemLabel(error: ErrorItem | undefined) {
|
||||
if (!error || error.isRuntimeError) return undefined
|
||||
return error.displayItemLabel
|
||||
}
|
||||
|
||||
function getInlineDetails(error: ErrorItem | undefined, idx: number) {
|
||||
if (getInlineItemLabel(error)) return undefined
|
||||
return displayedDetailsMap.value[idx]
|
||||
}
|
||||
|
||||
function getRuntimeDetailsId(idx: number) {
|
||||
return `${card.id}-runtime-details-${idx}`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import TabErrors from './TabErrors.vue'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
const mockFocusNode = vi.hoisted(() => vi.fn())
|
||||
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
@@ -41,13 +38,6 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useFocusNode', () => ({
|
||||
useFocusNode: vi.fn(() => ({
|
||||
focusNode: mockFocusNode,
|
||||
enterSubgraph: mockEnterSubgraph
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
|
||||
downloadModel: vi.fn(),
|
||||
fetchModelMetadata: vi.fn().mockResolvedValue({
|
||||
@@ -62,7 +52,6 @@ describe('TabErrors.vue', () => {
|
||||
let i18n: ReturnType<typeof createI18n>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -70,22 +59,11 @@ describe('TabErrors.vue', () => {
|
||||
en: {
|
||||
g: {
|
||||
workflow: 'Workflow',
|
||||
copy: 'Copy',
|
||||
details: 'Details',
|
||||
findOnGithub: 'Find on GitHub',
|
||||
getHelpAction: 'Get Help'
|
||||
copy: 'Copy'
|
||||
},
|
||||
rightSidePanel: {
|
||||
noErrors: 'No errors',
|
||||
noneSearchDesc: 'No results found',
|
||||
errorHelp: 'Error help',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues',
|
||||
getHelpTooltip: 'Get help',
|
||||
info: 'Info',
|
||||
infoFor: 'Info for {item}',
|
||||
locateNode: 'Locate node',
|
||||
locateNodeFor: 'Locate {item}',
|
||||
missingModels: {
|
||||
missingModelsTitle: 'Missing Models',
|
||||
downloadAll: 'Download all',
|
||||
@@ -166,111 +144,29 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders node validation errors grouped by catalog copy', async () => {
|
||||
it('renders node validation errors grouped by class_type', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
|
||||
const titles: Record<string, string> = {
|
||||
'1': 'KSampler',
|
||||
'2': 'CLIP Text Encode'
|
||||
}
|
||||
return {
|
||||
title: titles[String(nodeId)] ?? ''
|
||||
} as ReturnType<typeof getNodeByExecutionId>
|
||||
})
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'CLIP Text Encode'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const { user } = renderComponent({
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'2': {
|
||||
'6': {
|
||||
class_type: 'CLIPTextEncode',
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: clip',
|
||||
extra_info: {
|
||||
input_name: 'clip'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: positive',
|
||||
extra_info: {
|
||||
input_name: 'positive'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'Input: model',
|
||||
extra_info: {
|
||||
input_name: 'model'
|
||||
}
|
||||
}
|
||||
{ message: 'Required input is missing', details: 'Input: text' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.getByText('Missing connection')).toBeInTheDocument()
|
||||
expect(screen.getByText('(3)')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
'Required input slots have no connection feeding them.'
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(screen.queryByText('#1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('#2')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('CLIP Text Encode')).not.toBeInTheDocument()
|
||||
|
||||
const itemRows = screen.getAllByRole('listitem')
|
||||
expect(itemRows).toHaveLength(3)
|
||||
expect(itemRows[0]).toHaveTextContent('KSampler - model')
|
||||
expect(itemRows[1]).toHaveTextContent('KSampler - positive')
|
||||
expect(itemRows[2]).toHaveTextContent('CLIP Text Encode - clip')
|
||||
|
||||
const infoButton = within(itemRows[1]).getByRole('button', {
|
||||
name: 'Info for KSampler - positive'
|
||||
})
|
||||
|
||||
await user.click(infoButton)
|
||||
|
||||
const itemDetail = screen.getByText(
|
||||
'KSampler is missing a required input: positive'
|
||||
)
|
||||
expect(infoButton).toHaveAttribute(
|
||||
'aria-controls',
|
||||
itemDetail.getAttribute('id')
|
||||
)
|
||||
|
||||
const labelLocateButton = within(itemRows[1]).getByRole('button', {
|
||||
name: 'KSampler - positive'
|
||||
})
|
||||
|
||||
await user.click(labelLocateButton)
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('1')
|
||||
|
||||
const iconLocateButton = within(itemRows[2]).getByRole('button', {
|
||||
name: 'Locate CLIP Text Encode - clip'
|
||||
})
|
||||
|
||||
await user.click(iconLocateButton)
|
||||
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('2')
|
||||
|
||||
expect(
|
||||
screen.queryByText('Required input is missing')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Input: model')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Input: positive')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Input: clip')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('CLIPTextEncode')).toBeInTheDocument()
|
||||
expect(screen.getByText('#6')).toBeInTheDocument()
|
||||
expect(screen.getByText('CLIP Text Encode')).toBeInTheDocument()
|
||||
expect(screen.getByText('Required input is missing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders runtime execution errors from WebSocket', async () => {
|
||||
@@ -279,7 +175,7 @@ describe('TabErrors.vue', () => {
|
||||
title: 'KSampler'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const { user } = renderComponent({
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
@@ -294,16 +190,12 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
|
||||
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('#10')).toBeInTheDocument()
|
||||
expect(screen.getByText('Execution failed')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('Node threw an error during execution.')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Error log')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Details' }))
|
||||
|
||||
expect(screen.queryByText(/Line 1/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters errors based on search query', async () => {
|
||||
@@ -338,7 +230,7 @@ describe('TabErrors.vue', () => {
|
||||
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls copyToClipboard when a runtime error copy button is clicked', async () => {
|
||||
it('calls copyToClipboard when copy button is clicked', async () => {
|
||||
const { useCopyToClipboard } =
|
||||
await import('@/composables/useCopyToClipboard')
|
||||
const mockCopy = vi.fn()
|
||||
@@ -346,26 +238,21 @@ describe('TabErrors.vue', () => {
|
||||
|
||||
const { user } = renderComponent({
|
||||
executionError: {
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
node_id: '1',
|
||||
node_type: 'TestNode',
|
||||
exception_message: 'Test message',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: ['Test details'],
|
||||
timestamp: Date.now()
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
class_type: 'TestNode',
|
||||
errors: [{ message: 'Test message', details: 'Test details' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('error-card-copy'))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith(
|
||||
'Node threw an error during execution.\n\nTest details'
|
||||
)
|
||||
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
|
||||
})
|
||||
|
||||
it('renders a single runtime error in the normal execution group', async () => {
|
||||
it('renders single runtime error outside accordion in full-height panel', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue({
|
||||
title: 'KSampler'
|
||||
@@ -387,11 +274,7 @@ describe('TabErrors.vue', () => {
|
||||
|
||||
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('Execution failed')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByTestId('error-group-execution')).getByTestId(
|
||||
'runtime-error-panel'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Execution failed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,32 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<!-- Runtime error: full-height panel outside accordion -->
|
||||
<div
|
||||
v-if="singleRuntimeErrorCard"
|
||||
data-testid="runtime-error-panel"
|
||||
aria-live="polite"
|
||||
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
|
||||
>
|
||||
{{ singleRuntimeErrorGroup?.displayTitle }}
|
||||
</div>
|
||||
<ErrorNodeCard
|
||||
:key="singleRuntimeErrorCard.id"
|
||||
:card="singleRuntimeErrorCard"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
full-height
|
||||
class="min-h-0 flex-1"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content (non-runtime or mixed errors) -->
|
||||
<div v-else class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
|
||||
<TransitionGroup tag="div" name="list-scale" class="relative">
|
||||
<div
|
||||
v-if="filteredGroups.length === 0"
|
||||
@@ -45,13 +70,10 @@
|
||||
{{ group.displayTitle }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
group.type === 'execution' &&
|
||||
getExecutionGroupCount(group) > 1
|
||||
"
|
||||
v-if="group.type === 'execution' && group.cards.length > 1"
|
||||
class="text-destructive-background-hover"
|
||||
>
|
||||
({{ getExecutionGroupCount(group) }})
|
||||
({{ group.cards.length }})
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
@@ -133,7 +155,7 @@
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="group.displayMessage"
|
||||
v-if="group.type !== 'execution' && group.displayMessage"
|
||||
data-testid="error-group-display-message"
|
||||
class="px-4 pt-1 pb-3"
|
||||
>
|
||||
@@ -164,79 +186,12 @@
|
||||
/>
|
||||
|
||||
<!-- Execution Errors -->
|
||||
<div v-if="isExecutionItemListGroup(group)" class="px-4">
|
||||
<ul class="m-0 list-none space-y-1 p-0">
|
||||
<li
|
||||
v-for="item in getExecutionItemList(group)"
|
||||
:key="item.key"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<button
|
||||
v-tooltip.top="{
|
||||
value: item.displayDetails || undefined,
|
||||
showDelay: 300
|
||||
}"
|
||||
type="button"
|
||||
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
|
||||
@click="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="item.displayDetails"
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
:class="
|
||||
cn(
|
||||
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
|
||||
isExecutionItemDetailExpanded(item.key) &&
|
||||
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
t('rightSidePanel.infoFor', { item: item.label })
|
||||
"
|
||||
:aria-controls="getExecutionItemDetailId(item.key)"
|
||||
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
|
||||
@click.stop="toggleExecutionItemDetail(item.key)"
|
||||
>
|
||||
<i class="icon-[lucide--info] size-3.5" />
|
||||
</Button>
|
||||
</span>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="icon-sm"
|
||||
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="
|
||||
t('rightSidePanel.locateNodeFor', { item: item.label })
|
||||
"
|
||||
@click.stop="handleLocateNode(item.nodeId)"
|
||||
>
|
||||
<i class="icon-[lucide--locate] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TransitionCollapse>
|
||||
<p
|
||||
v-if="
|
||||
item.displayDetails &&
|
||||
isExecutionItemDetailExpanded(item.key)
|
||||
"
|
||||
:id="getExecutionItemDetailId(item.key)"
|
||||
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
|
||||
>
|
||||
{{ item.displayDetails }}
|
||||
</p>
|
||||
</TransitionCollapse>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<div v-if="group.type === 'execution'" class="space-y-3 px-4">
|
||||
<ErrorNodeCard
|
||||
v-for="card in group.cards"
|
||||
:key="card.id"
|
||||
:card="card"
|
||||
:show-node-id-badge="showNodeIdBadge"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@@ -300,7 +255,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
import { useFocusNode } from '@/composables/canvas/useFocusNode'
|
||||
@@ -312,7 +266,6 @@ import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
|
||||
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
|
||||
import TransitionCollapse from '../layout/TransitionCollapse.vue'
|
||||
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import MissingNodeCard from './MissingNodeCard.vue'
|
||||
@@ -332,13 +285,6 @@ import type { SwapNodeGroup } from './useErrorGroups'
|
||||
import type { ErrorGroup } from './types'
|
||||
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
|
||||
|
||||
interface ExecutionItemListEntry {
|
||||
key: string
|
||||
nodeId: string
|
||||
label: string
|
||||
displayDetails?: string
|
||||
}
|
||||
|
||||
const ErrorPanelSurveyCta =
|
||||
isNightly && !isCloud && !isDesktop
|
||||
? defineAsyncComponent(
|
||||
@@ -361,7 +307,6 @@ const { isInstalling: isInstallingAll, installAllPacks: installAll } =
|
||||
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const expandedExecutionItemDetailKeys = ref(new Set<string>())
|
||||
const isSearching = computed(() => searchQuery.value.trim() !== '')
|
||||
|
||||
const fullSizeGroupTypes = new Set([
|
||||
@@ -380,78 +325,6 @@ const showNodeIdBadge = computed(
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
|
||||
function isExecutionItemListGroup(group: ErrorGroup) {
|
||||
return (
|
||||
group.type === 'execution' &&
|
||||
group.cards.length > 0 &&
|
||||
group.cards.every(
|
||||
(card) =>
|
||||
card.nodeId &&
|
||||
card.errors.length > 0 &&
|
||||
card.errors.every(
|
||||
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
|
||||
if (group.type !== 'execution') return []
|
||||
|
||||
const items: ExecutionItemListEntry[] = []
|
||||
for (const card of group.cards) {
|
||||
if (!card.nodeId) continue
|
||||
for (let idx = 0; idx < card.errors.length; idx++) {
|
||||
const error = card.errors[idx]
|
||||
const label = error.displayItemLabel
|
||||
if (!label) continue
|
||||
items.push({
|
||||
key: `${card.id}:${idx}`,
|
||||
nodeId: card.nodeId,
|
||||
label,
|
||||
displayDetails: error.displayDetails
|
||||
})
|
||||
}
|
||||
}
|
||||
return items.sort(compareExecutionItemListEntry)
|
||||
}
|
||||
|
||||
function compareExecutionItemListEntry(
|
||||
a: ExecutionItemListEntry,
|
||||
b: ExecutionItemListEntry
|
||||
) {
|
||||
return (
|
||||
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
|
||||
a.label.localeCompare(b.label)
|
||||
)
|
||||
}
|
||||
|
||||
function getExecutionGroupCount(group: ErrorGroup) {
|
||||
if (group.type !== 'execution') return 0
|
||||
if (isExecutionItemListGroup(group)) {
|
||||
return group.cards.reduce((count, card) => count + card.errors.length, 0)
|
||||
}
|
||||
return group.cards.length
|
||||
}
|
||||
|
||||
function isExecutionItemDetailExpanded(key: string) {
|
||||
return expandedExecutionItemDetailKeys.value.has(key)
|
||||
}
|
||||
|
||||
function toggleExecutionItemDetail(key: string) {
|
||||
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
|
||||
if (nextKeys.has(key)) {
|
||||
nextKeys.delete(key)
|
||||
} else {
|
||||
nextKeys.add(key)
|
||||
}
|
||||
expandedExecutionItemDetailKeys.value = nextKeys
|
||||
}
|
||||
|
||||
function getExecutionItemDetailId(key: string) {
|
||||
return `execution-item-detail-${key}`
|
||||
}
|
||||
|
||||
const {
|
||||
allErrorGroups,
|
||||
tabErrorGroups,
|
||||
@@ -483,6 +356,20 @@ function handleMissingModelRefresh() {
|
||||
void missingModelStore.refreshMissingModels()
|
||||
}
|
||||
|
||||
const singleRuntimeErrorGroup = computed(() => {
|
||||
if (filteredGroups.value.length !== 1) return null
|
||||
const group = filteredGroups.value[0]
|
||||
const isSoleRuntimeError =
|
||||
group.type === 'execution' &&
|
||||
group.cards.length === 1 &&
|
||||
group.cards[0].errors.every((e) => e.isRuntimeError)
|
||||
return isSoleRuntimeError ? group : null
|
||||
})
|
||||
|
||||
const singleRuntimeErrorCard = computed(
|
||||
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
|
||||
)
|
||||
|
||||
const isAllCollapsed = computed({
|
||||
get() {
|
||||
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
|
||||
|
||||
@@ -23,9 +23,6 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
|
||||
}))
|
||||
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
const unknownValidationMessage = vi.hoisted(
|
||||
() => 'A node returned a validation error ComfyUI does not recognize.'
|
||||
)
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
@@ -46,18 +43,6 @@ vi.mock('@/i18n', () => {
|
||||
'Required input missing',
|
||||
'errorCatalog.validationErrors.required_input_missing.toastMessage':
|
||||
'{nodeName} is missing a required input: {inputName}',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.title':
|
||||
'Validation failed',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.message':
|
||||
unknownValidationMessage,
|
||||
'errorCatalog.validationErrors.unknown_validation_error.detailsWithRawDetails':
|
||||
'{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.itemLabel':
|
||||
'{nodeName}',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.toastTitle':
|
||||
'Validation failed',
|
||||
'errorCatalog.validationErrors.unknown_validation_error.toastMessage':
|
||||
'{nodeName} returned an unrecognized validation error.',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.title':
|
||||
'Prompt has no outputs',
|
||||
'errorCatalog.promptErrors.prompt_no_outputs.desc':
|
||||
@@ -399,7 +384,7 @@ describe('useErrorGroups', () => {
|
||||
expect(swapIdx).toBeLessThan(missingIdx)
|
||||
})
|
||||
|
||||
it('uses fallback catalog grouping for unknown node validation errors', async () => {
|
||||
it('includes execution error groups from node errors', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
@@ -420,8 +405,8 @@ describe('useErrorGroups', () => {
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroups.length).toBeGreaterThan(0)
|
||||
expect(execGroups[0].groupKey).toBe('execution:unknown_validation_error')
|
||||
expect(execGroups[0].displayTitle).toBe('Validation failed')
|
||||
expect(execGroups[0].groupKey).toBe('execution:KSampler')
|
||||
expect(execGroups[0].displayTitle).toBe('KSampler')
|
||||
})
|
||||
|
||||
it('resolves required_input_missing item display copy', async () => {
|
||||
@@ -470,55 +455,6 @@ describe('useErrorGroups', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('groups node validation errors by catalog id across node types', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
class_type: 'KSampler',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'model',
|
||||
extra_info: {
|
||||
input_name: 'model'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
'2': {
|
||||
class_type: 'CLIPLoader',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing',
|
||||
details: 'clip',
|
||||
extra_info: {
|
||||
input_name: 'clip'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
const execGroups = groups.allErrorGroups.value.filter(
|
||||
(g) => g.type === 'execution'
|
||||
)
|
||||
expect(execGroups).toHaveLength(1)
|
||||
|
||||
const [group] = execGroups
|
||||
expect(group.groupKey).toBe('execution:missing_connection')
|
||||
expect(group.displayTitle).toBe('Missing connection')
|
||||
expect(group.cards.map((card) => card.title)).toEqual([
|
||||
'KSampler',
|
||||
'CLIPLoader'
|
||||
])
|
||||
expect(group.cards.flatMap((card) => card.errors)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
|
||||
mockIsCloud.value = true
|
||||
const { store, groups } = createErrorGroups()
|
||||
@@ -780,7 +716,7 @@ describe('useErrorGroups', () => {
|
||||
expect(groups.groupedErrorMessages.value).toEqual([])
|
||||
})
|
||||
|
||||
it('collects unique display messages from node errors', async () => {
|
||||
it('collects unique error messages from node errors', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.lastNodeErrors = {
|
||||
'1': {
|
||||
@@ -800,7 +736,10 @@ describe('useErrorGroups', () => {
|
||||
await nextTick()
|
||||
|
||||
const messages = groups.groupedErrorMessages.value
|
||||
expect(messages).toEqual([unknownValidationMessage])
|
||||
expect(messages).toContain('Error A')
|
||||
expect(messages).toContain('Error B')
|
||||
// Deduplication: Error A appears twice but should only be listed once
|
||||
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('includes missing node group display message', async () => {
|
||||
|
||||
@@ -30,7 +30,6 @@ import type {
|
||||
MissingModelCandidate,
|
||||
MissingModelGroup
|
||||
} from '@/platform/missingModel/types'
|
||||
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
|
||||
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
|
||||
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
|
||||
@@ -44,6 +43,7 @@ import {
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
const SINGLE_GROUP_KEY = '__single__'
|
||||
|
||||
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
|
||||
const RESOLVING = '__RESOLVING__'
|
||||
@@ -66,7 +66,6 @@ export interface SwapNodeGroup {
|
||||
interface GroupEntry {
|
||||
type: 'execution'
|
||||
displayTitle: string
|
||||
displayMessage?: string
|
||||
priority: number
|
||||
cards: Map<string, ErrorCardData>
|
||||
}
|
||||
@@ -76,14 +75,10 @@ interface ErrorSearchItem {
|
||||
cardIndex: number
|
||||
searchableNodeId: string
|
||||
searchableNodeTitle: string
|
||||
searchableRawMessage: string
|
||||
searchableRawDetails: string
|
||||
searchableMessage: string
|
||||
searchableDetails: string
|
||||
}
|
||||
|
||||
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
|
||||
|
||||
/**
|
||||
* Resolve display info for a node by its execution ID.
|
||||
* For group node internals, resolves the parent group node's title instead.
|
||||
@@ -111,21 +106,17 @@ function getOrCreateGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
groupKey: string,
|
||||
displayTitle = groupKey,
|
||||
priority = 1,
|
||||
displayMessage?: string
|
||||
priority = 1
|
||||
): Map<string, ErrorCardData> {
|
||||
let entry = groupsMap.get(groupKey)
|
||||
if (!entry) {
|
||||
entry = {
|
||||
type: 'execution',
|
||||
displayTitle,
|
||||
displayMessage,
|
||||
priority,
|
||||
cards: new Map()
|
||||
}
|
||||
groupsMap.set(groupKey, entry)
|
||||
} else if (!entry.displayMessage && displayMessage) {
|
||||
entry.displayMessage = displayMessage
|
||||
}
|
||||
return entry.cards
|
||||
}
|
||||
@@ -147,6 +138,44 @@ function createErrorCard(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* In single-node mode, regroup cards by error message instead of class_type.
|
||||
* This lets the user see "what kinds of errors this node has" at a glance.
|
||||
*/
|
||||
function regroupByErrorMessage(
|
||||
groupsMap: Map<string, GroupEntry>
|
||||
): Map<string, GroupEntry> {
|
||||
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
|
||||
Array.from(g.cards.values())
|
||||
)
|
||||
|
||||
const cardErrorPairs = allCards.flatMap((card) =>
|
||||
card.errors.map((error) => ({ card, error }))
|
||||
)
|
||||
|
||||
const messageMap = new Map<string, GroupEntry>()
|
||||
for (const { card, error } of cardErrorPairs) {
|
||||
addCardErrorToGroup(messageMap, card, error)
|
||||
}
|
||||
|
||||
return messageMap
|
||||
}
|
||||
|
||||
function addCardErrorToGroup(
|
||||
messageMap: Map<string, GroupEntry>,
|
||||
card: ErrorCardData,
|
||||
error: ErrorItem
|
||||
) {
|
||||
const displayTitle =
|
||||
error.displayTitle ?? error.displayMessage ?? error.message
|
||||
const groupKey = error.catalogId ?? displayTitle
|
||||
const group = getOrCreateGroup(messageMap, groupKey, displayTitle, 1)
|
||||
if (!group.has(card.id)) {
|
||||
group.set(card.id, { ...card, errors: [] })
|
||||
}
|
||||
group.get(card.id)?.errors.push(error)
|
||||
}
|
||||
|
||||
function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
|
||||
return compareExecutionId(a.nodeId, b.nodeId)
|
||||
}
|
||||
@@ -157,7 +186,6 @@ function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
|
||||
type: 'execution' as const,
|
||||
groupKey: `execution:${rawGroupKey}`,
|
||||
displayTitle: groupData.displayTitle,
|
||||
displayMessage: groupData.displayMessage,
|
||||
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
|
||||
priority: groupData.priority
|
||||
}))
|
||||
@@ -181,8 +209,6 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
cardIndex: ci,
|
||||
searchableNodeId: card.nodeId ?? '',
|
||||
searchableNodeTitle: card.nodeTitle ?? '',
|
||||
searchableRawMessage: card.errors.map((e) => e.message).join(' '),
|
||||
searchableRawDetails: card.errors.map((e) => e.details).join(' '),
|
||||
searchableMessage: card.errors
|
||||
.map((e) =>
|
||||
[e.displayTitle, e.displayMessage, e.message]
|
||||
@@ -199,11 +225,9 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
|
||||
|
||||
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
|
||||
keys: [
|
||||
{ name: 'searchableRawMessage', weight: 0.3 },
|
||||
{ name: 'searchableNodeId', weight: 0.2 },
|
||||
{ name: 'searchableNodeTitle', weight: 0.2 },
|
||||
{ name: 'searchableMessage', weight: 0.2 },
|
||||
{ name: 'searchableRawDetails', weight: 0.1 },
|
||||
{ name: 'searchableNodeId', weight: 0.3 },
|
||||
{ name: 'searchableNodeTitle', weight: 0.3 },
|
||||
{ name: 'searchableMessage', weight: 0.3 },
|
||||
{ name: 'searchableDetails', weight: 0.1 }
|
||||
],
|
||||
threshold: 0.3
|
||||
@@ -309,23 +333,18 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
nodeId: string,
|
||||
classType: string,
|
||||
idPrefix: string,
|
||||
error: CataloguedErrorItem,
|
||||
errors: ErrorItem[],
|
||||
filterBySelection = false
|
||||
) {
|
||||
if (filterBySelection && !isErrorInSelection(nodeId)) return
|
||||
const cards = getOrCreateGroup(
|
||||
groupsMap,
|
||||
error.catalogId,
|
||||
error.displayTitle ?? classType,
|
||||
1,
|
||||
error.displayMessage
|
||||
)
|
||||
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
|
||||
const cards = getOrCreateGroup(groupsMap, groupKey, classType, 1)
|
||||
if (!cards.has(nodeId)) {
|
||||
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
|
||||
}
|
||||
const card = cards.get(nodeId)
|
||||
if (!card) return
|
||||
card.errors.push(error)
|
||||
card.errors.push(...errors)
|
||||
}
|
||||
|
||||
function processPromptError(
|
||||
@@ -349,8 +368,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
groupsMap,
|
||||
`prompt:${error.type}`,
|
||||
groupDisplayTitle,
|
||||
0,
|
||||
resolvedDisplay.displayMessage
|
||||
0
|
||||
)
|
||||
|
||||
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
|
||||
@@ -377,13 +395,13 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
)) {
|
||||
const nodeDisplayName =
|
||||
resolveNodeInfo(nodeId).title || nodeError.class_type
|
||||
for (const e of nodeError.errors) {
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
nodeId,
|
||||
nodeError.class_type,
|
||||
'node',
|
||||
{
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
nodeId,
|
||||
nodeError.class_type,
|
||||
'node',
|
||||
nodeError.errors.map((e) => {
|
||||
return {
|
||||
message: e.message,
|
||||
details: e.details ?? undefined,
|
||||
...resolveRunErrorMessage({
|
||||
@@ -391,10 +409,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
error: e,
|
||||
nodeDisplayName
|
||||
})
|
||||
},
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,18 +428,20 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
String(e.node_id),
|
||||
e.node_type,
|
||||
'exec',
|
||||
{
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type,
|
||||
...resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName:
|
||||
resolveNodeInfo(String(e.node_id)).title || e.node_type
|
||||
})
|
||||
},
|
||||
[
|
||||
{
|
||||
message: `${e.exception_type}: ${e.exception_message}`,
|
||||
details: e.traceback.join('\n'),
|
||||
isRuntimeError: true,
|
||||
exceptionType: e.exception_type,
|
||||
...resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName:
|
||||
resolveNodeInfo(String(e.node_id)).title || e.node_type
|
||||
})
|
||||
}
|
||||
],
|
||||
filterBySelection
|
||||
)
|
||||
}
|
||||
@@ -847,6 +867,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
processNodeErrors(groupsMap, true)
|
||||
processExecutionError(groupsMap, true)
|
||||
|
||||
const executionGroups = isSingleNodeSelected.value
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
|
||||
const filterByNode = selectedNodeInfo.value.nodeIds !== null
|
||||
|
||||
// Missing nodes are intentionally unfiltered — they represent
|
||||
@@ -859,7 +883,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...(filterByNode
|
||||
? buildMissingMediaGroupsFiltered()
|
||||
: buildMissingMediaGroups()),
|
||||
...toSortedGroups(groupsMap)
|
||||
...executionGroups
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -92,7 +92,6 @@ import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
|
||||
@@ -127,8 +126,6 @@ const placeholder = computed(() => {
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeFrequencyStore = useNodeFrequencyStore()
|
||||
|
||||
useSearchQueryTracking('node_modal', currentQuery, suggestions)
|
||||
|
||||
// Debounced search tracking (500ms as per implementation plan)
|
||||
const debouncedTrackSearch = debounce((query: string) => {
|
||||
if (query.trim()) {
|
||||
|
||||
@@ -121,7 +121,6 @@ import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
|
||||
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
|
||||
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
|
||||
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
@@ -345,8 +344,6 @@ const hoveredNodeDef = computed(
|
||||
() => displayedResults.value[selectedIndex.value] ?? null
|
||||
)
|
||||
|
||||
useSearchQueryTracking('node_modal', searchQuery, displayedResults)
|
||||
|
||||
watch(
|
||||
hoveredNodeDef,
|
||||
(newVal) => {
|
||||
|
||||
@@ -166,7 +166,6 @@ import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
@@ -213,7 +212,6 @@ const filteredWorkflows = computed(() => {
|
||||
workflow.path.toLocaleLowerCase().includes(lowerQuery)
|
||||
)
|
||||
})
|
||||
useSearchQueryTracking('apps', searchQuery, filteredWorkflows)
|
||||
const filteredRoot = computed<TreeNode>(() => {
|
||||
return buildWorkflowTree(filteredWorkflows.value as ComfyWorkflow[])
|
||||
})
|
||||
|
||||
@@ -190,7 +190,6 @@ import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import {
|
||||
DEFAULT_GROUPING_ID,
|
||||
@@ -338,7 +337,6 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
|
||||
})
|
||||
|
||||
const filteredNodeDefs = ref<ComfyNodeDefImpl[]>([])
|
||||
useSearchQueryTracking('node_sidebar', searchQuery, filteredNodeDefs)
|
||||
const filters: Ref<
|
||||
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
|
||||
> = ref([])
|
||||
|
||||
@@ -190,7 +190,6 @@ import SidebarTopArea from '@/components/sidebar/tabs/SidebarTopArea.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import { usePerTabState } from '@/composables/usePerTabState'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import {
|
||||
DEFAULT_SORTING_ID,
|
||||
DEFAULT_TAB_ID,
|
||||
@@ -290,8 +289,6 @@ const activeNodes = computed(() =>
|
||||
: filteredNodeDefs.value
|
||||
)
|
||||
|
||||
useSearchQueryTracking('node_sidebar', searchQuery, filteredNodeDefs)
|
||||
|
||||
const hasNoMatches = computed(
|
||||
() => searchQuery.value.length > 0 && filteredNodeDefs.value.length === 0
|
||||
)
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { computeArrangement } from '@/composables/graph/useArrangeNodes'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
interface MockNodeSpec {
|
||||
id: number | string
|
||||
pos: [number, number]
|
||||
size: [number, number]
|
||||
title_mode?: TitleMode
|
||||
}
|
||||
|
||||
const makeNode = (spec: MockNodeSpec): LGraphNode =>
|
||||
({
|
||||
id: spec.id,
|
||||
pos: spec.pos,
|
||||
size: spec.size,
|
||||
title_mode: spec.title_mode
|
||||
}) as unknown as LGraphNode
|
||||
|
||||
const GAP = 12
|
||||
const TITLE = 30 // LiteGraph.NODE_TITLE_HEIGHT default
|
||||
|
||||
describe('computeArrangement', () => {
|
||||
it('returns no updates when fewer than 2 nodes are selected', () => {
|
||||
expect(computeArrangement([], 'vertical')).toEqual([])
|
||||
expect(
|
||||
computeArrangement(
|
||||
[makeNode({ id: 1, pos: [0, 0], size: [100, 50] })],
|
||||
'grid'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
describe('vertical', () => {
|
||||
it('left-aligns to anchor x and stacks downward sorted by current y', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'a', pos: [10, 100], size: [100, 50] }),
|
||||
makeNode({ id: 'b', pos: [200, 0], size: [80, 30] }),
|
||||
makeNode({ id: 'c', pos: [50, 200], size: [120, 40] })
|
||||
]
|
||||
// Anchor: 'a' has smallest x+y (110). Sort by Y: b(0), a(100), c(200).
|
||||
// Visual top of layout = anchor.posY - TITLE = 100 - 30 = 70.
|
||||
// Each node's pos.y = visualTop + its titleHeight (30).
|
||||
// b: pos.y = 70+30 = 100; visualTop += (30+30)+12 = 142
|
||||
// a: pos.y = 142+30 = 172; visualTop += (50+30)+12 = 234
|
||||
// c: pos.y = 234+30 = 264
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 'b', position: { x: 10, y: 100 } },
|
||||
{ nodeId: 'a', position: { x: 10, y: 172 } },
|
||||
{ nodeId: 'c', position: { x: 10, y: 264 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('omits the title-height contribution for NO_TITLE nodes', () => {
|
||||
const nodes = [
|
||||
makeNode({
|
||||
id: 1,
|
||||
pos: [0, 0],
|
||||
size: [100, 100],
|
||||
title_mode: TitleMode.NO_TITLE
|
||||
}),
|
||||
makeNode({
|
||||
id: 2,
|
||||
pos: [0, 200],
|
||||
size: [100, 100],
|
||||
title_mode: TitleMode.NO_TITLE
|
||||
})
|
||||
]
|
||||
// No titles: visualHeight = size[1] = 100. visualTop = 0. Gap = 12.
|
||||
// 1: pos.y = 0; visualTop = 0 + 100 + 12 = 112.
|
||||
// 2: pos.y = 112.
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 0, y: 100 + GAP } }
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves heterogeneous heights when computing gaps', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 1, pos: [0, 0], size: [100, 200] }),
|
||||
makeNode({ id: 2, pos: [0, 50], size: [100, 50] })
|
||||
]
|
||||
// visualTop=-30. 1: pos.y=0; visualTop += (200+30)+12 = 212.
|
||||
// 2: pos.y = 212+30 = 242.
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 0, y: 200 + TITLE + GAP } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('horizontal', () => {
|
||||
it('top-aligns to anchor y and lays out rightward sorted by current x', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'a', pos: [100, 50], size: [80, 40] }),
|
||||
makeNode({ id: 'b', pos: [0, 200], size: [60, 30] }),
|
||||
makeNode({ id: 'c', pos: [300, 80], size: [50, 50] })
|
||||
]
|
||||
// Anchor: smallest x+y → a(150), b(200), c(380) → anchor 'a' at (100, 50).
|
||||
// Sort by X: b(0), a(100), c(300)
|
||||
// Lay out from (100, 50):
|
||||
// b at (100, 50)
|
||||
// a at (100 + 60 + 12, 50) = (172, 50)
|
||||
// c at (172 + 80 + 12, 50) = (264, 50)
|
||||
const result = computeArrangement(nodes, 'horizontal')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 'b', position: { x: 100, y: 50 } },
|
||||
{ nodeId: 'a', position: { x: 172, y: 50 } },
|
||||
{ nodeId: 'c', position: { x: 264, y: 50 } }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('grid', () => {
|
||||
it('lays out 4 nodes as 2x2 with column/row sizes from max width/height', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 1, pos: [0, 0], size: [100, 50] }),
|
||||
makeNode({ id: 2, pos: [200, 0], size: [80, 60] }),
|
||||
makeNode({ id: 3, pos: [0, 100], size: [120, 40] }),
|
||||
makeNode({ id: 4, pos: [200, 100], size: [90, 30] })
|
||||
]
|
||||
// Anchor: 1 at (0,0). Sort by Y then X: 1, 2, 3, 4. cols=2, rows=2.
|
||||
// Col widths: col0=max(100,120)=120; col1=max(80,90)=90.
|
||||
// Row visual heights: row0=max(50+30,60+30)=90; row1=max(40+30,30+30)=70.
|
||||
// colX=[0, 132]. rowVisualTop=[-30, -30+90+12=72].
|
||||
// pos.y = rowVisualTop + 30 (titleHeight).
|
||||
const result = computeArrangement(nodes, 'grid')
|
||||
expect(result).toEqual([
|
||||
{ nodeId: 1, position: { x: 0, y: 0 } },
|
||||
{ nodeId: 2, position: { x: 132, y: 0 } },
|
||||
{ nodeId: 3, position: { x: 0, y: 102 } },
|
||||
{ nodeId: 4, position: { x: 132, y: 102 } }
|
||||
])
|
||||
})
|
||||
|
||||
it('uses ceil(sqrt(n)) columns for non-square counts', () => {
|
||||
// 5 nodes → ceil(sqrt(5))=3 cols, 2 rows. Last cell empty.
|
||||
const nodes = Array.from({ length: 5 }, (_, i) =>
|
||||
makeNode({
|
||||
id: i + 1,
|
||||
pos: [i * 50, i * 50],
|
||||
size: [40, 40]
|
||||
})
|
||||
)
|
||||
const result = computeArrangement(nodes, 'grid')
|
||||
expect(result).toHaveLength(5)
|
||||
// Sorted by Y then X = original order. Anchor = node 1 at (0,0).
|
||||
// colWidths=[40,40,40]. rowVisualHeight = 40+30 = 70 each.
|
||||
// colX=[0,52,104]. rowVisualTop=[-30, -30+70+12=52]. pos.y = visualTop+30.
|
||||
expect(result[0].position).toEqual({ x: 0, y: 0 })
|
||||
expect(result[1].position).toEqual({ x: 52, y: 0 })
|
||||
expect(result[2].position).toEqual({ x: 104, y: 0 })
|
||||
expect(result[3].position).toEqual({ x: 0, y: 82 })
|
||||
expect(result[4].position).toEqual({ x: 52, y: 82 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('anchor selection', () => {
|
||||
it('picks the node with smallest x+y, not min-x or min-y alone', () => {
|
||||
const nodes = [
|
||||
// min y but large x: x+y = 1000
|
||||
makeNode({ id: 'minY', pos: [1000, 0], size: [50, 50] }),
|
||||
// min x but large y: x+y = 1000
|
||||
makeNode({ id: 'minX', pos: [0, 1000], size: [50, 50] }),
|
||||
// smallest x+y: 600
|
||||
makeNode({ id: 'anchor', pos: [300, 300], size: [50, 50] })
|
||||
]
|
||||
const result = computeArrangement(nodes, 'vertical')
|
||||
// All updates left-align to anchor.x = 300. First in sort = minY (y=0).
|
||||
expect(result[0]).toEqual({
|
||||
nodeId: 'minY',
|
||||
position: { x: 300, y: 300 }
|
||||
})
|
||||
expect(result.every((u) => u.position.x === 300)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,186 +0,0 @@
|
||||
import { useSelectionState } from '@/composables/graph/useSelectionState'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
|
||||
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { Point } from '@/renderer/core/layout/types'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export type ArrangeLayout = 'vertical' | 'horizontal' | 'grid'
|
||||
|
||||
export const DEFAULT_ARRANGE_GAP = 12
|
||||
export const MIN_ARRANGE_GAP = 0
|
||||
export const MAX_ARRANGE_GAP = 48
|
||||
|
||||
interface NodeBox {
|
||||
id: NodeId
|
||||
posX: number
|
||||
posY: number
|
||||
visualWidth: number
|
||||
visualHeight: number
|
||||
titleHeight: number
|
||||
}
|
||||
|
||||
interface ArrangeUpdate {
|
||||
nodeId: NodeId
|
||||
position: Point
|
||||
}
|
||||
|
||||
const titleHeightOf = (node: LGraphNode): number => {
|
||||
const mode = node.title_mode
|
||||
if (mode === TitleMode.TRANSPARENT_TITLE || mode === TitleMode.NO_TITLE) {
|
||||
return 0
|
||||
}
|
||||
return LiteGraph.NODE_TITLE_HEIGHT
|
||||
}
|
||||
|
||||
const toBox = (node: LGraphNode): NodeBox => {
|
||||
const titleHeight = titleHeightOf(node)
|
||||
return {
|
||||
id: node.id,
|
||||
posX: node.pos[0],
|
||||
posY: node.pos[1],
|
||||
visualWidth: node.size[0],
|
||||
visualHeight: node.size[1] + titleHeight,
|
||||
titleHeight
|
||||
}
|
||||
}
|
||||
|
||||
const byTopDown = (a: NodeBox, b: NodeBox) => a.posY - b.posY || a.posX - b.posX
|
||||
|
||||
const byLeftRight = (a: NodeBox, b: NodeBox) =>
|
||||
a.posX - b.posX || a.posY - b.posY
|
||||
|
||||
const findAnchor = (boxes: NodeBox[]): NodeBox =>
|
||||
boxes.reduce((best, box) =>
|
||||
box.posX + box.posY < best.posX + best.posY ? box : best
|
||||
)
|
||||
|
||||
const cumulativeOffsets = (
|
||||
sizes: number[],
|
||||
origin: number,
|
||||
gap: number
|
||||
): number[] => {
|
||||
const offsets: number[] = [origin]
|
||||
for (let i = 1; i < sizes.length; i++) {
|
||||
offsets.push(offsets[i - 1] + sizes[i - 1] + gap)
|
||||
}
|
||||
return offsets
|
||||
}
|
||||
|
||||
const arrangeVertical = (
|
||||
boxes: NodeBox[],
|
||||
anchor: NodeBox,
|
||||
gap: number
|
||||
): ArrangeUpdate[] => {
|
||||
const sorted = [...boxes].sort(byTopDown)
|
||||
let visualTop = anchor.posY - anchor.titleHeight
|
||||
return sorted.map((box) => {
|
||||
const update: ArrangeUpdate = {
|
||||
nodeId: box.id,
|
||||
position: { x: anchor.posX, y: visualTop + box.titleHeight }
|
||||
}
|
||||
visualTop += box.visualHeight + gap
|
||||
return update
|
||||
})
|
||||
}
|
||||
|
||||
const arrangeHorizontal = (
|
||||
boxes: NodeBox[],
|
||||
anchor: NodeBox,
|
||||
gap: number
|
||||
): ArrangeUpdate[] => {
|
||||
const sorted = [...boxes].sort(byLeftRight)
|
||||
const visualTop = anchor.posY - anchor.titleHeight
|
||||
let cursorX = anchor.posX
|
||||
return sorted.map((box) => {
|
||||
const update: ArrangeUpdate = {
|
||||
nodeId: box.id,
|
||||
position: { x: cursorX, y: visualTop + box.titleHeight }
|
||||
}
|
||||
cursorX += box.visualWidth + gap
|
||||
return update
|
||||
})
|
||||
}
|
||||
|
||||
const arrangeGrid = (
|
||||
boxes: NodeBox[],
|
||||
anchor: NodeBox,
|
||||
gap: number
|
||||
): ArrangeUpdate[] => {
|
||||
const sorted = [...boxes].sort(byTopDown)
|
||||
const cols = Math.ceil(Math.sqrt(sorted.length))
|
||||
const rows = Math.ceil(sorted.length / cols)
|
||||
|
||||
const colWidths = new Array<number>(cols).fill(0)
|
||||
const rowHeights = new Array<number>(rows).fill(0)
|
||||
sorted.forEach((box, i) => {
|
||||
const col = i % cols
|
||||
const row = Math.floor(i / cols)
|
||||
if (box.visualWidth > colWidths[col]) colWidths[col] = box.visualWidth
|
||||
if (box.visualHeight > rowHeights[row]) rowHeights[row] = box.visualHeight
|
||||
})
|
||||
|
||||
const colX = cumulativeOffsets(colWidths, anchor.posX, gap)
|
||||
const rowVisualTop = cumulativeOffsets(
|
||||
rowHeights,
|
||||
anchor.posY - anchor.titleHeight,
|
||||
gap
|
||||
)
|
||||
|
||||
return sorted.map((box, i) => {
|
||||
const col = i % cols
|
||||
const row = Math.floor(i / cols)
|
||||
return {
|
||||
nodeId: box.id,
|
||||
position: {
|
||||
x: colX[col],
|
||||
y: rowVisualTop[row] + box.titleHeight
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function computeArrangement(
|
||||
nodes: LGraphNode[],
|
||||
layout: ArrangeLayout,
|
||||
gap: number = DEFAULT_ARRANGE_GAP
|
||||
): ArrangeUpdate[] {
|
||||
if (nodes.length < 2) return []
|
||||
const boxes = nodes.map(toBox)
|
||||
const anchor = findAnchor(boxes)
|
||||
if (layout === 'vertical') return arrangeVertical(boxes, anchor, gap)
|
||||
if (layout === 'horizontal') return arrangeHorizontal(boxes, anchor, gap)
|
||||
return arrangeGrid(boxes, anchor, gap)
|
||||
}
|
||||
|
||||
interface ArrangeOptions {
|
||||
gap?: number
|
||||
captureUndo?: boolean
|
||||
}
|
||||
|
||||
export function useArrangeNodes() {
|
||||
const { selectedNodes, hasMultipleSelection } = useSelectionState()
|
||||
const mutations = useLayoutMutations()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const arrangeNodes = (
|
||||
layout: ArrangeLayout,
|
||||
{ gap = DEFAULT_ARRANGE_GAP, captureUndo = true }: ArrangeOptions = {}
|
||||
) => {
|
||||
if (!hasMultipleSelection.value) return
|
||||
const updates = computeArrangement(selectedNodes.value, layout, gap)
|
||||
if (updates.length === 0) return
|
||||
|
||||
mutations.setSource(LayoutSource.Canvas)
|
||||
mutations.batchMoveNodes(updates)
|
||||
app.canvas?.setDirty(true, true)
|
||||
if (captureUndo) {
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
}
|
||||
}
|
||||
|
||||
return { arrangeNodes, canArrange: hasMultipleSelection }
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as ArrangeNodesModule from '@/composables/graph/useArrangeNodes'
|
||||
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
|
||||
|
||||
const mockArrangeNodes = vi.fn()
|
||||
|
||||
vi.mock('@/composables/graph/useArrangeNodes', async () => {
|
||||
const actual = await vi.importActual<typeof ArrangeNodesModule>(
|
||||
'@/composables/graph/useArrangeNodes'
|
||||
)
|
||||
return {
|
||||
...actual,
|
||||
useArrangeNodes: () => ({ arrangeNodes: mockArrangeNodes })
|
||||
}
|
||||
})
|
||||
|
||||
describe('useArrangeSession', () => {
|
||||
let frameCallbacks: Array<FrameRequestCallback>
|
||||
let nextHandle: number
|
||||
|
||||
beforeEach(() => {
|
||||
mockArrangeNodes.mockReset()
|
||||
frameCallbacks = []
|
||||
nextHandle = 1
|
||||
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(
|
||||
(cb: FrameRequestCallback) => {
|
||||
frameCallbacks.push(cb)
|
||||
return nextHandle++
|
||||
}
|
||||
)
|
||||
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((id) => {
|
||||
frameCallbacks[id - 1] = () => {}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const flushFrames = () => {
|
||||
const callbacks = frameCallbacks
|
||||
frameCallbacks = []
|
||||
callbacks.forEach((cb) => cb(performance.now()))
|
||||
}
|
||||
|
||||
it('start() applies layout immediately and captures undo', () => {
|
||||
const session = useArrangeSession()
|
||||
session.start('vertical')
|
||||
|
||||
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockArrangeNodes).toHaveBeenCalledWith('vertical', {
|
||||
gap: 12,
|
||||
captureUndo: true
|
||||
})
|
||||
expect(session.activeLayout.value).toBe('vertical')
|
||||
expect(session.gap.value).toBe(12)
|
||||
})
|
||||
|
||||
it('previewGap() throttles repeated calls into a single frame', () => {
|
||||
const session = useArrangeSession()
|
||||
session.start('grid')
|
||||
mockArrangeNodes.mockClear()
|
||||
|
||||
session.previewGap(20)
|
||||
session.previewGap(30)
|
||||
session.previewGap(40)
|
||||
|
||||
expect(mockArrangeNodes).not.toHaveBeenCalled()
|
||||
flushFrames()
|
||||
|
||||
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockArrangeNodes).toHaveBeenCalledWith('grid', {
|
||||
gap: 40,
|
||||
captureUndo: false
|
||||
})
|
||||
})
|
||||
|
||||
it('previewGap() is a no-op outside an active session', () => {
|
||||
const session = useArrangeSession()
|
||||
session.previewGap(20)
|
||||
flushFrames()
|
||||
expect(mockArrangeNodes).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commitGap() cancels any pending preview frame', () => {
|
||||
const session = useArrangeSession()
|
||||
session.start('horizontal')
|
||||
mockArrangeNodes.mockClear()
|
||||
|
||||
session.previewGap(25)
|
||||
session.commitGap(36)
|
||||
flushFrames()
|
||||
|
||||
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
|
||||
expect(mockArrangeNodes).toHaveBeenCalledWith('horizontal', {
|
||||
gap: 36,
|
||||
captureUndo: true
|
||||
})
|
||||
})
|
||||
|
||||
it('reset() ends the session and prevents pending frames from arranging', () => {
|
||||
const session = useArrangeSession()
|
||||
session.start('vertical')
|
||||
mockArrangeNodes.mockClear()
|
||||
|
||||
session.previewGap(40)
|
||||
session.reset()
|
||||
flushFrames()
|
||||
|
||||
expect(mockArrangeNodes).not.toHaveBeenCalled()
|
||||
expect(session.activeLayout.value).toBeNull()
|
||||
expect(session.gap.value).toBe(12)
|
||||
})
|
||||
})
|
||||
@@ -1,60 +0,0 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
import {
|
||||
DEFAULT_ARRANGE_GAP,
|
||||
useArrangeNodes
|
||||
} from '@/composables/graph/useArrangeNodes'
|
||||
import type { ArrangeLayout } from '@/composables/graph/useArrangeNodes'
|
||||
|
||||
export function useArrangeSession() {
|
||||
const { arrangeNodes } = useArrangeNodes()
|
||||
|
||||
const activeLayout = ref<ArrangeLayout | null>(null)
|
||||
const gap = ref(DEFAULT_ARRANGE_GAP)
|
||||
let pendingFrame: number | null = null
|
||||
|
||||
const cancelPendingFrame = () => {
|
||||
if (pendingFrame === null) return
|
||||
cancelAnimationFrame(pendingFrame)
|
||||
pendingFrame = null
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
cancelPendingFrame()
|
||||
activeLayout.value = null
|
||||
gap.value = DEFAULT_ARRANGE_GAP
|
||||
}
|
||||
|
||||
const start = (layout: ArrangeLayout) => {
|
||||
gap.value = DEFAULT_ARRANGE_GAP
|
||||
activeLayout.value = layout
|
||||
arrangeNodes(layout, { gap: gap.value, captureUndo: true })
|
||||
}
|
||||
|
||||
const previewGap = (nextGap: number) => {
|
||||
if (activeLayout.value === null) return
|
||||
gap.value = nextGap
|
||||
cancelPendingFrame()
|
||||
pendingFrame = requestAnimationFrame(() => {
|
||||
pendingFrame = null
|
||||
if (activeLayout.value === null) return
|
||||
arrangeNodes(activeLayout.value, { gap: nextGap, captureUndo: false })
|
||||
})
|
||||
}
|
||||
|
||||
const commitGap = (nextGap: number) => {
|
||||
if (activeLayout.value === null) return
|
||||
cancelPendingFrame()
|
||||
gap.value = nextGap
|
||||
arrangeNodes(activeLayout.value, { gap: nextGap, captureUndo: true })
|
||||
}
|
||||
|
||||
return {
|
||||
activeLayout: readonly(activeLayout),
|
||||
gap: readonly(gap),
|
||||
start,
|
||||
previewGap,
|
||||
commitGap,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
CanvasPointer,
|
||||
CanvasPointerEvent,
|
||||
LGraphCanvas
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -290,7 +285,14 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
|
||||
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
|
||||
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
|
||||
// with that name so the slot-name filter matches.
|
||||
seedRequiredInputMissingNodeError(
|
||||
store,
|
||||
interiorExecId,
|
||||
promotedWidget!.name
|
||||
)
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
@@ -302,227 +304,6 @@ describe('Widget change error clearing via onWidgetChanged', () => {
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears range errors for promoted widgets by interior widget name', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'steps_input', type: 'INT' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('KSampler')
|
||||
const interiorInput = interiorNode.addInput('steps_input', 'INT')
|
||||
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
|
||||
min: 1,
|
||||
max: 100
|
||||
})
|
||||
interiorInput.widget = { name: 'steps' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const store = useExecutionErrorStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
store.lastNodeErrors = {
|
||||
[interiorExecId]: {
|
||||
errors: [
|
||||
{
|
||||
type: 'value_bigger_than_max',
|
||||
message: 'Too big',
|
||||
details: '',
|
||||
extra_info: { input_name: 'steps' }
|
||||
}
|
||||
],
|
||||
dependent_outputs: [],
|
||||
class_type: 'KSampler'
|
||||
}
|
||||
}
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'steps',
|
||||
50,
|
||||
150,
|
||||
promotedWidget!
|
||||
)
|
||||
|
||||
expect(store.lastNodeErrors).toBeNull()
|
||||
})
|
||||
|
||||
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [{ name: 'ckpt_input', type: '*' }]
|
||||
})
|
||||
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
interiorNode.type = 'CheckpointLoaderSimple'
|
||||
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
||||
interiorNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
interiorInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(interiorNode)
|
||||
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, {
|
||||
id: 65,
|
||||
pos: [0, 0],
|
||||
size: [200, 100]
|
||||
})
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: interiorExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
|
||||
const promotedWidget = subgraphNode.widgets?.find(
|
||||
(widget) =>
|
||||
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
|
||||
)
|
||||
expect(promotedWidget).toBeDefined()
|
||||
|
||||
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
|
||||
canvasX: 190,
|
||||
canvasY: 20,
|
||||
deltaX: 0
|
||||
})
|
||||
const pointer = fromAny<CanvasPointer, unknown>({
|
||||
eDown: clickEvent
|
||||
})
|
||||
const canvas = fromAny<LGraphCanvas, unknown>({
|
||||
graph_mouse: [190, 20],
|
||||
last_mouseclick: 0
|
||||
})
|
||||
|
||||
const handled = promotedWidget!.onPointerDown?.(
|
||||
pointer,
|
||||
subgraphNode,
|
||||
canvas
|
||||
)
|
||||
expect(handled).toBe(true)
|
||||
expect(pointer.onClick).toBeDefined()
|
||||
|
||||
pointer.onClick?.(clickEvent)
|
||||
|
||||
expect(missingModelStore.missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
|
||||
const subgraph = createTestSubgraph({
|
||||
inputs: [
|
||||
{ name: 'first_ckpt', type: '*' },
|
||||
{ name: 'second_ckpt', type: '*' }
|
||||
]
|
||||
})
|
||||
const firstNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
firstNode.type = 'CheckpointLoaderSimple'
|
||||
const firstInput = firstNode.addInput('first_ckpt', '*')
|
||||
const firstWidget = firstNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
firstInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(firstNode)
|
||||
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
||||
|
||||
const secondNode = new LGraphNode('CheckpointLoaderSimple')
|
||||
secondNode.type = 'CheckpointLoaderSimple'
|
||||
const secondInput = secondNode.addInput('second_ckpt', '*')
|
||||
secondNode.addWidget(
|
||||
'combo',
|
||||
'ckpt_name',
|
||||
'missing.safetensors',
|
||||
() => undefined,
|
||||
{ values: ['missing.safetensors', 'present.safetensors'] }
|
||||
)
|
||||
secondInput.widget = { name: 'ckpt_name' }
|
||||
subgraph.add(secondNode)
|
||||
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const graph = subgraphNode.graph as LGraph
|
||||
graph.add(subgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
||||
installErrorClearingHooks(graph)
|
||||
|
||||
const promotedWidgets =
|
||||
subgraphNode.widgets?.filter(
|
||||
(widget) =>
|
||||
'sourceWidgetName' in widget &&
|
||||
widget.sourceWidgetName === 'ckpt_name'
|
||||
) ?? []
|
||||
expect(promotedWidgets).toHaveLength(2)
|
||||
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
|
||||
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
|
||||
missingModelStore.setMissingModels([
|
||||
{
|
||||
nodeId: firstExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate,
|
||||
{
|
||||
nodeId: secondExecId,
|
||||
nodeType: 'CheckpointLoaderSimple',
|
||||
widgetName: 'ckpt_name',
|
||||
isAssetSupported: false,
|
||||
name: 'missing.safetensors',
|
||||
isMissing: true
|
||||
} satisfies MissingModelCandidate
|
||||
])
|
||||
|
||||
firstWidget.value = 'present.safetensors'
|
||||
subgraphNode.onWidgetChanged!.call(
|
||||
subgraphNode,
|
||||
'ckpt_name',
|
||||
'present.safetensors',
|
||||
'missing.safetensors',
|
||||
firstWidget
|
||||
)
|
||||
|
||||
expect(missingModelStore.missingModelCandidates).toEqual([
|
||||
expect.objectContaining({
|
||||
nodeId: secondExecId,
|
||||
widgetName: 'ckpt_name',
|
||||
name: 'missing.safetensors'
|
||||
})
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('installErrorClearingHooks lifecycle', () => {
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
*/
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -46,128 +45,22 @@ import {
|
||||
isAncestorPathActive
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
interface WidgetErrorClearingTarget {
|
||||
executionId: string
|
||||
validationInputName: string
|
||||
assetWidgetName: string
|
||||
currentValue: unknown
|
||||
options?: { min?: number; max?: number }
|
||||
}
|
||||
|
||||
function getWidgetRangeOptions(widget: IBaseWidget): {
|
||||
min?: number
|
||||
max?: number
|
||||
} {
|
||||
return {
|
||||
min: widget.options?.min,
|
||||
max: widget.options?.max
|
||||
}
|
||||
}
|
||||
|
||||
function plainWidgetToErrorTarget(
|
||||
function resolvePromotedExecId(
|
||||
rootGraph: LGraph,
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string
|
||||
): WidgetErrorClearingTarget {
|
||||
return {
|
||||
executionId: hostExecId,
|
||||
validationInputName: widget.name,
|
||||
assetWidgetName: widget.name,
|
||||
currentValue: widget.value,
|
||||
options: getWidgetRangeOptions(widget)
|
||||
}
|
||||
}
|
||||
|
||||
function promotedWidgetToErrorTarget(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: PromotedWidgetView,
|
||||
hostExecId: string
|
||||
): WidgetErrorClearingTarget {
|
||||
): string {
|
||||
if (!isPromotedWidgetView(widget)) return hostExecId
|
||||
const result = resolveConcretePromotedWidget(
|
||||
hostNode,
|
||||
node,
|
||||
widget.sourceNodeId,
|
||||
widget.sourceWidgetName
|
||||
)
|
||||
const execId =
|
||||
result.status === 'resolved' && result.resolved.node
|
||||
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
|
||||
: hostExecId
|
||||
const resolvedWidget =
|
||||
result.status === 'resolved' ? result.resolved.widget : widget
|
||||
|
||||
return {
|
||||
executionId: execId,
|
||||
validationInputName: resolvedWidget.name,
|
||||
assetWidgetName: widget.sourceWidgetName,
|
||||
currentValue: resolvedWidget.value,
|
||||
options: getWidgetRangeOptions(resolvedWidget)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCanvasPathPromotedWidgetTargets(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string,
|
||||
newValue: unknown
|
||||
): WidgetErrorClearingTarget[] {
|
||||
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
|
||||
|
||||
// Canvas-path events lose promoted identity, so the post-write value
|
||||
// disambiguates same-named promoted widgets.
|
||||
return (hostNode.widgets ?? [])
|
||||
.filter(isPromotedWidgetView)
|
||||
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
|
||||
.map((promotedWidget) =>
|
||||
promotedWidgetToErrorTarget(
|
||||
rootGraph,
|
||||
hostNode,
|
||||
promotedWidget,
|
||||
hostExecId
|
||||
)
|
||||
)
|
||||
.filter((target) => Object.is(target.currentValue, newValue))
|
||||
}
|
||||
|
||||
function resolveWidgetErrorTargets(
|
||||
rootGraph: LGraph,
|
||||
hostNode: LGraphNode,
|
||||
widget: IBaseWidget,
|
||||
hostExecId: string,
|
||||
newValue: unknown
|
||||
): WidgetErrorClearingTarget[] {
|
||||
if (isPromotedWidgetView(widget)) {
|
||||
return [
|
||||
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
|
||||
]
|
||||
}
|
||||
|
||||
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
|
||||
rootGraph,
|
||||
hostNode,
|
||||
widget,
|
||||
hostExecId,
|
||||
newValue
|
||||
)
|
||||
return canvasPathTargets.length
|
||||
? canvasPathTargets
|
||||
: [plainWidgetToErrorTarget(widget, hostExecId)]
|
||||
}
|
||||
|
||||
function clearWidgetErrorTargets(
|
||||
targets: WidgetErrorClearingTarget[],
|
||||
newValue: unknown
|
||||
): void {
|
||||
const store = useExecutionErrorStore()
|
||||
for (const target of targets) {
|
||||
store.clearWidgetRelatedErrors(
|
||||
target.executionId,
|
||||
target.validationInputName,
|
||||
target.assetWidgetName,
|
||||
newValue,
|
||||
target.options
|
||||
)
|
||||
if (result.status === 'resolved' && result.resolved.node) {
|
||||
return getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId
|
||||
}
|
||||
return hostExecId
|
||||
}
|
||||
|
||||
const hookedNodes = new WeakSet<LGraphNode>()
|
||||
@@ -210,14 +103,23 @@ function installNodeHooks(node: LGraphNode): void {
|
||||
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
|
||||
if (!hostExecId) return
|
||||
|
||||
const targets = resolveWidgetErrorTargets(
|
||||
const execId = resolvePromotedExecId(
|
||||
app.rootGraph,
|
||||
node,
|
||||
widget,
|
||||
hostExecId,
|
||||
newValue
|
||||
hostExecId
|
||||
)
|
||||
const widgetName = isPromotedWidgetView(widget)
|
||||
? widget.sourceWidgetName
|
||||
: widget.name
|
||||
|
||||
useExecutionErrorStore().clearWidgetRelatedErrors(
|
||||
execId,
|
||||
widget.name,
|
||||
widgetName,
|
||||
newValue,
|
||||
{ min: widget.options?.min, max: widget.options?.max }
|
||||
)
|
||||
clearWidgetErrorTargets(targets, newValue)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,14 +3,7 @@ import { nextTick, reactive, ref, shallowRef } from 'vue'
|
||||
import type { Pinia } from 'pinia'
|
||||
import { getActivePinia } from 'pinia'
|
||||
|
||||
import {
|
||||
getLoad3dOutputCache,
|
||||
isLoad3dSceneDirty,
|
||||
markLoad3dSceneDirty,
|
||||
nodeToLoad3dMap,
|
||||
setLoad3dOutputCache,
|
||||
useLoad3d
|
||||
} from '@/composables/useLoad3d'
|
||||
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
@@ -193,7 +186,6 @@ describe('useLoad3d', () => {
|
||||
resetGizmoTransform: vi.fn(),
|
||||
applyGizmoTransform: vi.fn(),
|
||||
fitToViewer: vi.fn(),
|
||||
centerCameraOnModel: vi.fn(),
|
||||
getGizmoTransform: vi.fn().mockReturnValue({
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
@@ -1750,184 +1742,4 @@ describe('useLoad3d', () => {
|
||||
expect(originalOnRemoved).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scene dirty tracking', () => {
|
||||
const fakeCache = {
|
||||
image: 'threed/scene-1.png [temp]',
|
||||
mask: 'threed/scene_mask-1.png [temp]',
|
||||
normal: 'threed/scene_normal-1.png [temp]',
|
||||
camera_info: null,
|
||||
recording: '',
|
||||
model_3d_info: []
|
||||
}
|
||||
|
||||
it('treats an unseen node as dirty by default', () => {
|
||||
const fresh = createMockLGraphNode({ properties: {} })
|
||||
expect(isLoad3dSceneDirty(fresh)).toBe(true)
|
||||
})
|
||||
|
||||
it('markLoad3dSceneDirty sets the node dirty', () => {
|
||||
const fresh = createMockLGraphNode({ properties: {} })
|
||||
setLoad3dOutputCache(fresh, fakeCache)
|
||||
expect(isLoad3dSceneDirty(fresh)).toBe(false)
|
||||
|
||||
markLoad3dSceneDirty(fresh)
|
||||
expect(isLoad3dSceneDirty(fresh)).toBe(true)
|
||||
})
|
||||
|
||||
it('setLoad3dOutputCache stores the output and clears dirty', () => {
|
||||
const fresh = createMockLGraphNode({ properties: {} })
|
||||
setLoad3dOutputCache(fresh, fakeCache)
|
||||
|
||||
expect(getLoad3dOutputCache(fresh)).toBe(fakeCache)
|
||||
expect(isLoad3dSceneDirty(fresh)).toBe(false)
|
||||
})
|
||||
|
||||
it('two nodes keep independent dirty state', () => {
|
||||
const a = createMockLGraphNode({ properties: {} })
|
||||
const b = createMockLGraphNode({ properties: {} })
|
||||
|
||||
setLoad3dOutputCache(a, fakeCache)
|
||||
expect(isLoad3dSceneDirty(a)).toBe(false)
|
||||
expect(isLoad3dSceneDirty(b)).toBe(true)
|
||||
|
||||
markLoad3dSceneDirty(a)
|
||||
expect(isLoad3dSceneDirty(a)).toBe(true)
|
||||
expect(isLoad3dSceneDirty(b)).toBe(true)
|
||||
})
|
||||
|
||||
it('markLoad3dSceneDirty on null is a no-op', () => {
|
||||
expect(() => markLoad3dSceneDirty(null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('sceneConfig changes flip the node dirty', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
|
||||
|
||||
composable.sceneConfig.value.backgroundColor = '#ffffff'
|
||||
await nextTick()
|
||||
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('cameraChanged event marks the node dirty', async () => {
|
||||
let cameraChangedHandler: ((state: unknown) => void) | undefined
|
||||
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
|
||||
(event: string, handler: unknown) => {
|
||||
if (event === 'cameraChanged') {
|
||||
cameraChangedHandler = handler as (state: unknown) => void
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
|
||||
|
||||
cameraChangedHandler!({ position: { x: 1, y: 2, z: 3 } })
|
||||
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handleStopRecording marks dirty when a recording was produced', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
|
||||
vi.mocked(mockLoad3d.getRecordingDuration!).mockReturnValue(5)
|
||||
composable.handleStopRecording()
|
||||
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handleStopRecording leaves dirty alone when no recording was produced', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
|
||||
vi.mocked(mockLoad3d.getRecordingDuration!).mockReturnValue(0)
|
||||
composable.handleStopRecording()
|
||||
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
|
||||
})
|
||||
|
||||
it('handleClearRecording marks dirty', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
composable.handleClearRecording()
|
||||
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handleStartRecording marks dirty so an in-progress recording forces a re-capture', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
|
||||
|
||||
await composable.handleStartRecording()
|
||||
|
||||
expect(mockLoad3d.startRecording).toHaveBeenCalledTimes(1)
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handleCenterCameraOnModel marks dirty', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
|
||||
|
||||
composable.handleCenterCameraOnModel()
|
||||
|
||||
expect(mockLoad3d.centerCameraOnModel).toHaveBeenCalledTimes(1)
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
|
||||
it('handleSeek marks dirty when the animation has a duration', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
await nextTick()
|
||||
|
||||
const calls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
|
||||
const match = calls.find(([event]) => event === 'animationProgressChange')
|
||||
const animationProgressHandler = match![1] as (d: {
|
||||
progress: number
|
||||
currentTime: number
|
||||
duration: number
|
||||
}) => void
|
||||
|
||||
animationProgressHandler({ progress: 0, currentTime: 0, duration: 10 })
|
||||
setLoad3dOutputCache(mockNode, fakeCache)
|
||||
|
||||
composable.handleSeek(50)
|
||||
|
||||
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,6 @@ import type {
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
Model3DInfo,
|
||||
ModelConfig,
|
||||
SceneConfig,
|
||||
UpDirection
|
||||
@@ -39,38 +38,6 @@ import { useLoad3dService } from '@/services/load3dService'
|
||||
|
||||
type Load3dReadyCallback = (load3d: Load3d) => void
|
||||
export const nodeToLoad3dMap = new Map<LGraphNode, Load3d>()
|
||||
|
||||
export type Load3dCachedOutput = {
|
||||
image: string
|
||||
mask: string
|
||||
normal: string
|
||||
camera_info: CameraState | null
|
||||
recording: string
|
||||
model_3d_info: Model3DInfo
|
||||
}
|
||||
|
||||
const load3dSceneDirty = new WeakMap<LGraphNode, boolean>()
|
||||
const load3dOutputCache = new WeakMap<LGraphNode, Load3dCachedOutput>()
|
||||
|
||||
export const markLoad3dSceneDirty = (node: LGraphNode | null): void => {
|
||||
if (!node) return
|
||||
load3dSceneDirty.set(node, true)
|
||||
}
|
||||
|
||||
export const isLoad3dSceneDirty = (node: LGraphNode): boolean =>
|
||||
load3dSceneDirty.get(node) !== false
|
||||
|
||||
export const getLoad3dOutputCache = (
|
||||
node: LGraphNode
|
||||
): Load3dCachedOutput | undefined => load3dOutputCache.get(node)
|
||||
|
||||
export const setLoad3dOutputCache = (
|
||||
node: LGraphNode,
|
||||
output: Load3dCachedOutput
|
||||
): void => {
|
||||
load3dOutputCache.set(node, output)
|
||||
load3dSceneDirty.set(node, false)
|
||||
}
|
||||
const pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
|
||||
const persistentReadyCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
|
||||
|
||||
@@ -102,11 +69,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
let load3d: Load3d | null = null
|
||||
let isFirstModelLoad = true
|
||||
|
||||
const markDirty = () => {
|
||||
const rawNode = toRaw(nodeRef.value)
|
||||
if (rawNode) markLoad3dSceneDirty(rawNode as LGraphNode)
|
||||
}
|
||||
|
||||
const debouncedHandleResize = useDebounceFn(() => {
|
||||
load3d?.handleResize()
|
||||
}, 150)
|
||||
@@ -409,7 +371,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (n) {
|
||||
n.properties['Light Config'] = lightConfig.value
|
||||
}
|
||||
markDirty()
|
||||
}
|
||||
|
||||
const waitForLoad3d = (callback: Load3dReadyCallback) => {
|
||||
@@ -454,7 +415,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (nodeRef.value) {
|
||||
nodeRef.value.properties['Scene Config'] = newValue
|
||||
}
|
||||
markDirty()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
@@ -495,7 +455,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (nodeRef.value) {
|
||||
nodeRef.value.properties['Model Config'] = newValue
|
||||
}
|
||||
markDirty()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
@@ -529,7 +488,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
load3d.toggleCamera(newValue.cameraType)
|
||||
load3d.setFOV(newValue.fov)
|
||||
}
|
||||
markDirty()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
@@ -589,21 +547,18 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (load3d) {
|
||||
load3d.toggleAnimation(newValue)
|
||||
}
|
||||
markDirty()
|
||||
})
|
||||
|
||||
watch(selectedSpeed, (newValue) => {
|
||||
if (load3d && newValue) {
|
||||
load3d.setAnimationSpeed(newValue)
|
||||
}
|
||||
markDirty()
|
||||
})
|
||||
|
||||
watch(selectedAnimation, (newValue) => {
|
||||
if (load3d && newValue !== undefined) {
|
||||
load3d.updateSelectedAnimation(newValue)
|
||||
}
|
||||
markDirty()
|
||||
})
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
@@ -618,7 +573,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (load3d) {
|
||||
await load3d.startRecording()
|
||||
isRecording.value = true
|
||||
markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,7 +582,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
isRecording.value = false
|
||||
recordingDuration.value = load3d.getRecordingDuration()
|
||||
hasRecording.value = recordingDuration.value > 0
|
||||
if (hasRecording.value) markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,7 +598,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
load3d.clearRecording()
|
||||
hasRecording.value = false
|
||||
recordingDuration.value = 0
|
||||
markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,7 +605,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (load3d && animationDuration.value > 0) {
|
||||
const time = (progress / 100) * animationDuration.value
|
||||
load3d.setAnimationTime(time)
|
||||
markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -985,7 +936,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
state: cameraState
|
||||
}
|
||||
}
|
||||
markLoad3dSceneDirty(node)
|
||||
}
|
||||
},
|
||||
gizmoTransformChange: (data: GizmoConfig) => {
|
||||
@@ -1026,9 +976,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
}
|
||||
|
||||
const handleCenterCameraOnModel = () => {
|
||||
if (!load3d) return
|
||||
load3d.centerCameraOnModel()
|
||||
markDirty()
|
||||
load3d?.centerCameraOnModel()
|
||||
}
|
||||
|
||||
const handleResetGizmoTransform = () => {
|
||||
|
||||
@@ -53,15 +53,10 @@ vi.mock('@/stores/systemStatsStore', () => ({
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackTemplateFilterChanged: vi.fn(),
|
||||
trackSearchQuery: vi.fn()
|
||||
trackTemplateFilterChanged: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
|
||||
useSearchQueryTracking: vi.fn()
|
||||
}))
|
||||
|
||||
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Ref } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
@@ -308,7 +307,6 @@ export function useTemplateFiltering(
|
||||
|
||||
const filteredCount = computed(() => filteredTemplates.value.length)
|
||||
const totalCount = computed(() => visibleTemplates.value.length)
|
||||
useSearchQueryTracking('templates', searchQuery, filteredTemplates)
|
||||
|
||||
// Template filter tracking (debounced to avoid excessive events)
|
||||
const debouncedTrackFilterChange = debounce(() => {
|
||||
|
||||
55
src/composables/useTransformCompatOverlayProps.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { HintedString } from '@primevue/core'
|
||||
import type { InjectionKey } from 'vue'
|
||||
import { computed, inject } from 'vue'
|
||||
|
||||
/**
|
||||
* Options for configuring transform-compatible overlay props
|
||||
*/
|
||||
interface TransformCompatOverlayOptions {
|
||||
/**
|
||||
* Where to append the overlay. 'self' keeps overlay within component
|
||||
* for proper transform inheritance, 'body' teleports to document body
|
||||
*/
|
||||
appendTo?: HintedString<'body' | 'self'> | undefined | HTMLElement
|
||||
// Future: other props needed for transform compatibility
|
||||
// scrollTarget?: string | HTMLElement
|
||||
// autoZIndex?: boolean
|
||||
}
|
||||
|
||||
export const OverlayAppendToKey: InjectionKey<
|
||||
HintedString<'body' | 'self'> | undefined | HTMLElement
|
||||
> = Symbol('OverlayAppendTo')
|
||||
|
||||
/**
|
||||
* Composable that provides props to make PrimeVue overlay components
|
||||
* compatible with CSS-transformed parent elements.
|
||||
*
|
||||
* Vue nodes use CSS transforms for positioning/scaling. PrimeVue overlay
|
||||
* components (Select, MultiSelect, TreeSelect, etc.) teleport to document
|
||||
* body by default, breaking transform inheritance. This composable provides
|
||||
* the necessary props to keep overlays within their component elements.
|
||||
*
|
||||
* @param overrides - Optional overrides for specific use cases
|
||||
* @returns Computed props object to spread on PrimeVue overlay components
|
||||
*
|
||||
* @example
|
||||
* ```vue
|
||||
* <template>
|
||||
* <Select v-bind="overlayProps" />
|
||||
* </template>
|
||||
*
|
||||
* <script setup>
|
||||
* const overlayProps = useTransformCompatOverlayProps()
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useTransformCompatOverlayProps(
|
||||
overrides: TransformCompatOverlayOptions = {}
|
||||
) {
|
||||
const injectedAppendTo = inject(OverlayAppendToKey, undefined)
|
||||
|
||||
return computed(() => ({
|
||||
appendTo: injectedAppendTo ?? ('self' as const),
|
||||
...overrides
|
||||
}))
|
||||
}
|
||||
@@ -38,27 +38,13 @@ vi.mock('@/services/load3dService', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => {
|
||||
const sceneDirty = new WeakMap<LGraphNode, boolean>()
|
||||
const outputCache = new WeakMap<LGraphNode, unknown>()
|
||||
return {
|
||||
useLoad3d: () => ({
|
||||
waitForLoad3d: waitForLoad3dMock,
|
||||
onLoad3dReady: onLoad3dReadyMock
|
||||
}),
|
||||
nodeToLoad3dMap,
|
||||
markLoad3dSceneDirty: (node: LGraphNode | null) => {
|
||||
if (!node) return
|
||||
sceneDirty.set(node, true)
|
||||
},
|
||||
isLoad3dSceneDirty: (node: LGraphNode) => sceneDirty.get(node) !== false,
|
||||
getLoad3dOutputCache: (node: LGraphNode) => outputCache.get(node),
|
||||
setLoad3dOutputCache: (node: LGraphNode, value: unknown) => {
|
||||
outputCache.set(node, value)
|
||||
sceneDirty.set(node, false)
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
useLoad3d: () => ({
|
||||
waitForLoad3d: waitForLoad3dMock,
|
||||
onLoad3dReady: onLoad3dReadyMock
|
||||
}),
|
||||
nodeToLoad3dMap
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
@@ -187,7 +173,7 @@ function makePreview3DAdvancedNode(
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3DAdvanced' },
|
||||
size: [400, 550],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [{ name: 'viewport_state', value: '' }],
|
||||
widgets: overrides.widgets ?? [{ name: 'image', value: '' }],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
@@ -496,16 +482,13 @@ describe('Comfy.Load3D.nodeCreated', () => {
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
loadFolder: 'input',
|
||||
modelWidget: widgets[0],
|
||||
cameraState: undefined,
|
||||
width: widgets[1],
|
||||
height: widgets[2],
|
||||
onSceneInvalidated: expect.any(Function)
|
||||
})
|
||||
)
|
||||
expect(configureMock).toHaveBeenCalledWith({
|
||||
loadFolder: 'input',
|
||||
modelWidget: widgets[0],
|
||||
cameraState: undefined,
|
||||
width: widgets[1],
|
||||
height: widgets[2]
|
||||
})
|
||||
})
|
||||
|
||||
it('attaches a serializeValue function to the scene widget', async () => {
|
||||
@@ -800,9 +783,9 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
expect(load3dInstance.setCameraState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('attaches a camera-only serializeValue to the viewport_state widget', async () => {
|
||||
it('attaches a camera-only serializeValue to the image widget', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'viewport_state', value: '' }]
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
await preview3DAdvancedExt.nodeCreated(node)
|
||||
@@ -812,7 +795,7 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
|
||||
it('serializeValue returns live camera_info plus empty media fields, omitting model_3d_info when none', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'viewport_state', value: '' }]
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
const load3d = makeLoad3dMock()
|
||||
@@ -836,7 +819,7 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
|
||||
it('serializeValue wraps a present getModelInfo result in a single-element list', async () => {
|
||||
const { preview3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [{ name: 'viewport_state', value: '' }]
|
||||
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
|
||||
const node = makePreview3DAdvancedNode({ widgets })
|
||||
|
||||
const load3d = makeLoad3dMock()
|
||||
@@ -1031,95 +1014,3 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D scene widget serializeValue caching', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
function makeFullFakeLoad3d() {
|
||||
return {
|
||||
getCurrentCameraType: vi.fn(() => 'perspective'),
|
||||
cameraManager: { perspectiveCamera: { fov: 35 } },
|
||||
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
|
||||
stopRecording: vi.fn(),
|
||||
captureScene: vi.fn(async () => ({
|
||||
scene: 'scene-data',
|
||||
mask: 'mask-data',
|
||||
normal: 'normal-data'
|
||||
})),
|
||||
handleResize: vi.fn(),
|
||||
getModelInfo: vi.fn(() => null),
|
||||
getRecordingData: vi.fn(() => null)
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const useLoad3dModule = await import('@/composables/useLoad3d')
|
||||
const utilsModule = await import('@/extensions/core/load3d/Load3dUtils')
|
||||
const uploadTempImage = utilsModule.default.uploadTempImage as ReturnType<
|
||||
typeof vi.fn
|
||||
>
|
||||
let counter = 0
|
||||
uploadTempImage.mockImplementation(
|
||||
async (_data: unknown, kind: string) => ({
|
||||
name: `${kind}-${++counter}.png`
|
||||
})
|
||||
)
|
||||
|
||||
const widgets: FakeWidget[] = [
|
||||
{ name: 'model_file', value: 'm.glb' },
|
||||
{ name: 'width', value: 256 },
|
||||
{ name: 'height', value: 256 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
const node = makeLoad3DNode({ widgets, properties: {} })
|
||||
useLoad3dModule.nodeToLoad3dMap.set(node, makeFullFakeLoad3d() as never)
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
const serialize = widgets[3].serializeValue! as () => Promise<{
|
||||
image: string
|
||||
} | null>
|
||||
|
||||
return { node, serialize, uploadTempImage, useLoad3dModule }
|
||||
}
|
||||
|
||||
it('reuses the cached output when the scene has not been dirtied', async () => {
|
||||
const { node, serialize, uploadTempImage, useLoad3dModule } = await setup()
|
||||
|
||||
const first = await serialize()
|
||||
expect(uploadTempImage).toHaveBeenCalledTimes(3)
|
||||
expect(first?.image).toBe('threed/scene-1.png [temp]')
|
||||
expect(useLoad3dModule.isLoad3dSceneDirty(node)).toBe(false)
|
||||
expect(useLoad3dModule.getLoad3dOutputCache(node)).toBe(first)
|
||||
|
||||
const second = await serialize()
|
||||
expect(uploadTempImage).toHaveBeenCalledTimes(3)
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('re-captures after the scene is marked dirty', async () => {
|
||||
const { node, serialize, uploadTempImage, useLoad3dModule } = await setup()
|
||||
|
||||
await serialize()
|
||||
expect(uploadTempImage).toHaveBeenCalledTimes(3)
|
||||
|
||||
useLoad3dModule.markLoad3dSceneDirty(node)
|
||||
|
||||
const refreshed = await serialize()
|
||||
expect(uploadTempImage).toHaveBeenCalledTimes(6)
|
||||
expect(refreshed?.image).toBe('threed/scene-4.png [temp]')
|
||||
})
|
||||
|
||||
it('returns null when no load3d instance is registered for the node', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [
|
||||
{ name: 'model_file', value: 'm.glb' },
|
||||
{ name: 'width', value: 256 },
|
||||
{ name: 'height', value: 256 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
const node = makeLoad3DNode({ widgets })
|
||||
await load3DExt.nodeCreated(node)
|
||||
expect(await widgets[3].serializeValue!()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,15 +2,7 @@ import { nextTick } from 'vue'
|
||||
|
||||
import Load3D from '@/components/load3d/Load3D.vue'
|
||||
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
|
||||
import {
|
||||
type Load3dCachedOutput,
|
||||
getLoad3dOutputCache,
|
||||
isLoad3dSceneDirty,
|
||||
markLoad3dSceneDirty,
|
||||
nodeToLoad3dMap,
|
||||
setLoad3dOutputCache,
|
||||
useLoad3d
|
||||
} from '@/composables/useLoad3d'
|
||||
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
|
||||
import type {
|
||||
CameraConfig,
|
||||
@@ -104,8 +96,6 @@ async function handleModelUpload(files: FileList, node: LGraphNode) {
|
||||
|
||||
modelWidget.value = uploadPath
|
||||
}
|
||||
|
||||
markLoad3dSceneDirty(node)
|
||||
} catch (error) {
|
||||
console.error('Model upload failed:', error)
|
||||
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
|
||||
@@ -123,7 +113,6 @@ async function handleResourcesUpload(files: FileList, node: LGraphNode) {
|
||||
: '3d'
|
||||
|
||||
await Load3dUtils.uploadMultipleFiles(files, subfolder)
|
||||
markLoad3dSceneDirty(node)
|
||||
} catch (error) {
|
||||
console.error('Extra resources upload failed:', error)
|
||||
useToastStore().addAlert(t('toastMessages.extraResourcesUploadFailed'))
|
||||
@@ -281,16 +270,8 @@ useExtensionService().registerExtension({
|
||||
}
|
||||
],
|
||||
getCustomWidgets() {
|
||||
const VIEWPORT_STATE_NODES = new Set([
|
||||
'Preview3DAdvanced',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud'
|
||||
])
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
const inputName = VIEWPORT_STATE_NODES.has(node.constructor.comfyClass)
|
||||
? 'viewport_state'
|
||||
: 'image'
|
||||
const hasModelFileWidget = node.widgets?.some(
|
||||
(w) => w.name === 'model_file'
|
||||
)
|
||||
@@ -330,15 +311,14 @@ useExtensionService().registerExtension({
|
||||
if (modelWidget) {
|
||||
modelWidget.value = LOAD3D_NONE_MODEL
|
||||
}
|
||||
markLoad3dSceneDirty(node)
|
||||
})
|
||||
}
|
||||
|
||||
const widget = new ComponentWidgetImpl({
|
||||
node: node,
|
||||
name: inputName,
|
||||
name: 'image',
|
||||
component: Load3D,
|
||||
inputSpec: { ...inputSpecLoad3D, name: inputName },
|
||||
inputSpec: inputSpecLoad3D,
|
||||
options: {}
|
||||
})
|
||||
|
||||
@@ -389,8 +369,7 @@ useExtensionService().registerExtension({
|
||||
modelWidget,
|
||||
cameraState,
|
||||
width,
|
||||
height,
|
||||
onSceneInvalidated: () => markLoad3dSceneDirty(node)
|
||||
height
|
||||
})
|
||||
})
|
||||
|
||||
@@ -408,11 +387,6 @@ useExtensionService().registerExtension({
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isLoad3dSceneDirty(node)) {
|
||||
const cached = getLoad3dOutputCache(node)
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties[
|
||||
'Camera Config'
|
||||
] as CameraConfig | undefined) || {
|
||||
@@ -444,7 +418,7 @@ useExtensionService().registerExtension({
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
const returnVal: Load3dCachedOutput = {
|
||||
const returnVal = {
|
||||
image: `threed/${data.name} [temp]`,
|
||||
mask: `threed/${dataMask.name} [temp]`,
|
||||
normal: `threed/${dataNormal.name} [temp]`,
|
||||
@@ -461,11 +435,9 @@ useExtensionService().registerExtension({
|
||||
const [recording] = await Promise.all([
|
||||
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
|
||||
])
|
||||
returnVal.recording = `threed/${recording.name} [temp]`
|
||||
returnVal['recording'] = `threed/${recording.name} [temp]`
|
||||
}
|
||||
|
||||
setLoad3dOutputCache(node, returnVal)
|
||||
|
||||
return returnVal
|
||||
}
|
||||
}
|
||||
@@ -743,7 +715,7 @@ useExtensionService().registerExtension({
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
@@ -682,138 +682,3 @@ describe('Load3DConfiguration "none" model handling', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3DConfiguration.onSceneInvalidated', () => {
|
||||
function makeLoad3dMock(): Load3d {
|
||||
return {
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
clearModel: vi.fn(),
|
||||
setUpDirection: vi.fn(),
|
||||
setMaterialMode: vi.fn(),
|
||||
setTargetSize: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
toggleGrid: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
setBackgroundRenderMode: vi.fn(),
|
||||
toggleCamera: vi.fn(),
|
||||
setFOV: vi.fn(),
|
||||
setLightIntensity: vi.fn(),
|
||||
setHDRIIntensity: vi.fn(),
|
||||
setHDRIAsBackground: vi.fn(),
|
||||
setHDRIEnabled: vi.fn(),
|
||||
emitModelReady: vi.fn()
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
|
||||
})
|
||||
|
||||
it('width.callback invokes onSceneInvalidated', async () => {
|
||||
const onSceneInvalidated = vi.fn()
|
||||
const width = { value: 1024 } as unknown as IBaseWidget
|
||||
const height = { value: 1024 } as unknown as IBaseWidget
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
|
||||
config.configure({
|
||||
modelWidget: { value: 'none' } as unknown as IBaseWidget,
|
||||
loadFolder: 'input',
|
||||
width,
|
||||
height,
|
||||
onSceneInvalidated
|
||||
})
|
||||
await flush()
|
||||
|
||||
width.callback!(2048)
|
||||
|
||||
expect(onSceneInvalidated).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('height.callback invokes onSceneInvalidated', async () => {
|
||||
const onSceneInvalidated = vi.fn()
|
||||
const width = { value: 1024 } as unknown as IBaseWidget
|
||||
const height = { value: 1024 } as unknown as IBaseWidget
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
|
||||
config.configure({
|
||||
modelWidget: { value: 'none' } as unknown as IBaseWidget,
|
||||
loadFolder: 'input',
|
||||
width,
|
||||
height,
|
||||
onSceneInvalidated
|
||||
})
|
||||
await flush()
|
||||
|
||||
height.callback!(2048)
|
||||
|
||||
expect(onSceneInvalidated).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('model_file widget callback invokes onSceneInvalidated after the model loads', async () => {
|
||||
const onSceneInvalidated = vi.fn()
|
||||
const modelWidget = { value: 'none' } as unknown as IBaseWidget
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
|
||||
config.configure({
|
||||
modelWidget,
|
||||
loadFolder: 'input',
|
||||
onSceneInvalidated
|
||||
})
|
||||
await flush()
|
||||
|
||||
modelWidget.value = 'model.glb'
|
||||
await flush()
|
||||
|
||||
expect(onSceneInvalidated).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves any pre-existing model widget callback alongside the invalidation hook', async () => {
|
||||
const onSceneInvalidated = vi.fn()
|
||||
const original = vi.fn()
|
||||
const modelWidget = {
|
||||
value: 'none',
|
||||
callback: original
|
||||
} as unknown as IBaseWidget
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
|
||||
config.configure({
|
||||
modelWidget,
|
||||
loadFolder: 'input',
|
||||
onSceneInvalidated
|
||||
})
|
||||
await flush()
|
||||
|
||||
modelWidget.value = 'model.glb'
|
||||
await flush()
|
||||
|
||||
expect(original).toHaveBeenCalledWith('model.glb')
|
||||
expect(onSceneInvalidated).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('callbacks remain safe when onSceneInvalidated is omitted', async () => {
|
||||
const width = { value: 1024 } as unknown as IBaseWidget
|
||||
const height = { value: 1024 } as unknown as IBaseWidget
|
||||
const modelWidget = { value: 'none' } as unknown as IBaseWidget
|
||||
const config = new Load3DConfiguration(makeLoad3dMock())
|
||||
|
||||
config.configure({
|
||||
modelWidget,
|
||||
loadFolder: 'input',
|
||||
width,
|
||||
height
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(() => width.callback!(2048)).not.toThrow()
|
||||
expect(() => height.callback!(2048)).not.toThrow()
|
||||
expect(() => {
|
||||
modelWidget.value = 'model.glb'
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,14 +23,6 @@ type Load3DConfigurationSettings = {
|
||||
height?: IBaseWidget
|
||||
bgImagePath?: string
|
||||
silentOnNotFound?: boolean
|
||||
/**
|
||||
* Called when a user-driven change to one of the wired widgets
|
||||
* (model_file, width, height) makes the previously captured scene stale.
|
||||
* Backend caching covers these inputs by themselves; this hook lets the
|
||||
* caller invalidate any frontend-side capture cache so the next serialize
|
||||
* re-renders at the new state.
|
||||
*/
|
||||
onSceneInvalidated?: () => void
|
||||
}
|
||||
|
||||
const ANNOTATED_FILENAME_PATTERN = / \[(input|output|temp)\]$/
|
||||
@@ -71,33 +63,22 @@ class Load3DConfiguration {
|
||||
setting.modelWidget,
|
||||
setting.loadFolder,
|
||||
setting.cameraState,
|
||||
setting.silentOnNotFound ?? false,
|
||||
setting.onSceneInvalidated
|
||||
)
|
||||
this.setupTargetSize(
|
||||
setting.width,
|
||||
setting.height,
|
||||
setting.onSceneInvalidated
|
||||
setting.silentOnNotFound ?? false
|
||||
)
|
||||
this.setupTargetSize(setting.width, setting.height)
|
||||
this.setupDefaultProperties(setting.bgImagePath)
|
||||
}
|
||||
|
||||
private setupTargetSize(
|
||||
width?: IBaseWidget,
|
||||
height?: IBaseWidget,
|
||||
onSceneInvalidated?: () => void
|
||||
) {
|
||||
private setupTargetSize(width?: IBaseWidget, height?: IBaseWidget) {
|
||||
if (width && height) {
|
||||
this.load3d.setTargetSize(width.value as number, height.value as number)
|
||||
|
||||
width.callback = (value: number) => {
|
||||
this.load3d.setTargetSize(value, height.value as number)
|
||||
onSceneInvalidated?.()
|
||||
}
|
||||
|
||||
height.callback = (value: number) => {
|
||||
this.load3d.setTargetSize(width.value as number, value)
|
||||
onSceneInvalidated?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,8 +103,7 @@ class Load3DConfiguration {
|
||||
modelWidget: IBaseWidget,
|
||||
loadFolder: string,
|
||||
cameraState?: CameraState,
|
||||
silentOnNotFound: boolean = false,
|
||||
onSceneInvalidated?: () => void
|
||||
silentOnNotFound: boolean = false
|
||||
) {
|
||||
const onModelWidgetUpdate = this.createModelUpdateHandler(
|
||||
loadFolder,
|
||||
@@ -157,8 +137,6 @@ class Load3DConfiguration {
|
||||
if (originalCallback) {
|
||||
originalCallback(value)
|
||||
}
|
||||
|
||||
onSceneInvalidated?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('scene.ply')
|
||||
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
|
||||
'temp',
|
||||
'output',
|
||||
'scene.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
@@ -231,7 +231,7 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
|
||||
const node = makePreviewNode({
|
||||
widgets: [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'viewport_state', value: '' },
|
||||
{ name: 'image', value: '' },
|
||||
widthWidget,
|
||||
heightWidget
|
||||
]
|
||||
@@ -262,7 +262,7 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
|
||||
)
|
||||
const sceneWidget: FakeWidget & {
|
||||
serializeValue?: () => Promise<unknown>
|
||||
} = { name: 'viewport_state', value: '' }
|
||||
} = { name: 'image', value: '' }
|
||||
const node = makePreviewNode({
|
||||
widgets: [{ name: 'model_file', value: '' }, sceneWidget]
|
||||
})
|
||||
@@ -318,7 +318,7 @@ describe('Comfy.PreviewPointCloud.nodeCreated', () => {
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('pointcloud.ply')
|
||||
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
|
||||
'temp',
|
||||
'output',
|
||||
'pointcloud.ply',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
|
||||
@@ -46,7 +46,7 @@ function applyResultToLoad3d(
|
||||
}
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('temp', normalizedPath, {
|
||||
config.configureForSaveMesh('output', normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
@@ -119,7 +119,7 @@ function createPreview3DExtension(
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
|
||||
config.configureForSaveMesh('output', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
@@ -136,9 +136,7 @@ function createPreview3DExtension(
|
||||
})
|
||||
|
||||
waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find(
|
||||
(w) => w.name === 'viewport_state'
|
||||
)
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
|
||||
@@ -6155,25 +6155,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (!this.pointer.isDown) reroute.drawSlots(ctx)
|
||||
}
|
||||
|
||||
this.linkConnector.renderLinks.forEach((link: RenderLink) => {
|
||||
if (!link.disconnectOnDrop || !link.disconnectOrigin) return
|
||||
|
||||
const [originX, originY] = link.disconnectOrigin
|
||||
const radius = 35
|
||||
const to = this._getHighlightPosition()
|
||||
const distSquared = (originX - to[0]) ** 2 + (originY - to[1]) ** 2
|
||||
|
||||
ctx.save()
|
||||
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(originX + radius, originY)
|
||||
ctx.arc(originX, originY, radius, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
|
||||
link.disconnectOnDrop = distSquared < radius ** 2
|
||||
})
|
||||
const highlightPos = this._getHighlightPosition()
|
||||
this.linkConnector.renderLinks
|
||||
.filter((rl) => rl instanceof MovingInputLink)
|
||||
.forEach((rl) => rl.drawConnectionCircle(ctx, highlightPos))
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
@@ -873,10 +873,10 @@ export class LinkConnector {
|
||||
}
|
||||
|
||||
dropOnNothing(event: CanvasPointerEvent): void {
|
||||
remove<RenderLinkUnion>(
|
||||
remove(
|
||||
this.renderLinks,
|
||||
(link: RenderLink) => !!link.disconnectOnDrop
|
||||
).forEach((link) => (link as { disconnect(): void }).disconnect())
|
||||
(link) => link instanceof MovingInputLink && link.disconnectOnDrop
|
||||
).forEach((link) => (link as MovingLinkBase).disconnect())
|
||||
if (this.renderLinks.length === 0) return
|
||||
// For external event only.
|
||||
const mayContinue = this.events.dispatch('dropped-on-canvas', event)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { Reroute } from '@/lib/litegraph/src/Reroute'
|
||||
import type { CustomEventTarget } from '@/lib/litegraph/src/infrastructure/CustomEventTarget'
|
||||
@@ -26,7 +27,7 @@ export class MovingInputLink extends MovingLinkBase {
|
||||
readonly fromDirection: LinkDirection
|
||||
readonly fromSlotIndex: SlotIndex
|
||||
disconnectOnDrop: boolean
|
||||
readonly disconnectOrigin?: Point
|
||||
readonly disconnectOrigin: Point
|
||||
|
||||
constructor(
|
||||
network: LinkNetwork,
|
||||
@@ -136,4 +137,23 @@ export class MovingInputLink extends MovingLinkBase {
|
||||
disconnect(): boolean {
|
||||
return this.inputNode.disconnectInput(this.inputIndex, true)
|
||||
}
|
||||
|
||||
drawConnectionCircle(ctx: CanvasRenderingContext2D, to: Readonly<Point>) {
|
||||
if (!this.disconnectOnDrop) return
|
||||
|
||||
const [originX, originY] = this.disconnectOrigin
|
||||
const radius = 35
|
||||
const distSquared = (originX - to[0]) ** 2 + (originY - to[1]) ** 2
|
||||
|
||||
ctx.save()
|
||||
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(originX + radius, originY)
|
||||
ctx.arc(originX, originY, radius, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
|
||||
this.disconnectOnDrop = distSquared < radius ** 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,6 @@ export interface RenderLink {
|
||||
|
||||
readonly isIoNodeLink?: boolean
|
||||
|
||||
disconnectOnDrop?: boolean
|
||||
readonly disconnectOrigin?: Point
|
||||
|
||||
/**
|
||||
* Capability checks used for hit-testing and validation during drag.
|
||||
* Implementations should return `false` when a connection is not possible
|
||||
|
||||
@@ -24,8 +24,6 @@ export class ToInputFromIoNodeLink implements RenderLink {
|
||||
readonly fromPos: Point
|
||||
fromDirection: LinkDirection = LinkDirection.RIGHT
|
||||
readonly existingLink?: LLink
|
||||
disconnectOnDrop: boolean
|
||||
readonly disconnectOrigin?: Point
|
||||
readonly isIoNodeLink = true
|
||||
|
||||
constructor(
|
||||
@@ -46,11 +44,6 @@ export class ToInputFromIoNodeLink implements RenderLink {
|
||||
this.fromSlotIndex = outputIndex
|
||||
this.fromPos = fromReroute ? fromReroute.pos : fromSlot.pos
|
||||
this.existingLink = existingLink
|
||||
this.disconnectOnDrop = true
|
||||
|
||||
if (!existingLink) return
|
||||
const toNode = network.getNodeById(existingLink.target_id)
|
||||
this.disconnectOrigin = toNode?.getInputPos(existingLink.target_slot)
|
||||
}
|
||||
|
||||
canConnectToInput(inputNode: NodeLike, input: INodeInputSlot): boolean {
|
||||
|
||||
@@ -832,9 +832,7 @@
|
||||
"FILE_3D_KSPLAT": "FILE_3D_KSPLAT",
|
||||
"FILE_3D_OBJ": "ملف OBJ ثلاثي الأبعاد",
|
||||
"FILE_3D_PLY": "FILE_3D_PLY",
|
||||
"FILE_3D_POINT_CLOUD_ANY": "FILE_3D_POINT_CLOUD_ANY",
|
||||
"FILE_3D_SPLAT": "FILE_3D_SPLAT",
|
||||
"FILE_3D_SPLAT_ANY": "FILE_3D_SPLAT_ANY",
|
||||
"FILE_3D_SPZ": "FILE_3D_SPZ",
|
||||
"FILE_3D_STL": "ملف STL ثلاثي الأبعاد",
|
||||
"FILE_3D_USDZ": "ملف USDZ ثلاثي الأبعاد",
|
||||
@@ -948,46 +946,6 @@
|
||||
"inputName": "مدخل غير معروف",
|
||||
"nodeName": "هذه العقدة"
|
||||
},
|
||||
"missingErrors": {
|
||||
"missing_media": {
|
||||
"displayMessage": "مدخل وسائط مطلوب لم يتم تحديد ملف له.",
|
||||
"toastMessageMany": "يرجى تحديد مدخلات الوسائط المفقودة قبل تشغيل سير العمل هذا.",
|
||||
"toastMessageWithNode": "{nodeName} يفتقد ملف وسائط مطلوب.",
|
||||
"toastTitleMany": "مدخلات الوسائط المفقودة",
|
||||
"toastTitleOne": "مدخل الوسائط مفقود"
|
||||
},
|
||||
"missing_model": {
|
||||
"displayMessageCloud": "استورد نموذجًا أو افتح العقدة لاستبداله.",
|
||||
"displayMessageOss": "قم بتنزيل نموذج أو افتح العقدة لاستبداله.",
|
||||
"toastMessageManyCloud": "بعض النماذج غير مدعومة. يرجى اختيار نماذج أخرى.",
|
||||
"toastMessageManyOss": "{count} من ملفات النماذج مفقودة.",
|
||||
"toastMessageOneCloud": "هذا النموذج غير مدعوم. اختر نموذجًا آخر.",
|
||||
"toastMessageOneOss": "{nodeName} يفتقد ملف النموذج المطلوب.",
|
||||
"toastTitleMany": "النماذج المفقودة",
|
||||
"toastTitleManyCloud": "النماذج غير متوفرة على السحابة",
|
||||
"toastTitleOneCloud": "{modelName} غير متوفر على السحابة",
|
||||
"toastTitleOneOss": "{modelName} مفقود"
|
||||
},
|
||||
"missing_node": {
|
||||
"displayMessageCloud": "العُقد المخصصة المطلوبة غير مدعومة على السحابة. استبدلها بعُقد مدعومة.",
|
||||
"displayMessageOss": "قم بتثبيت الحزم المفقودة لاستخدام سير العمل هذا.",
|
||||
"toastMessageManyCloud": "يستخدم سير العمل هذا عُقدًا غير مدعومة على السحابة.",
|
||||
"toastMessageManyOss": "{count} من العُقد تتطلب حزم عُقد مفقودة.",
|
||||
"toastMessageOneCloud": "هذه العقدة غير مدعومة على السحابة.",
|
||||
"toastMessageOneOss": "يستخدم سير العمل هذا عقدة مخصصة غير مثبتة. قم بتثبيتها من السجل أو استبدل العقدة.",
|
||||
"toastTitleManyCloud": "العُقد غير متوفرة على السحابة",
|
||||
"toastTitleManyOss": "العُقد المفقودة",
|
||||
"toastTitleOneCloud": "{nodeType} غير متوفر على السحابة",
|
||||
"toastTitleOneOss": "العقدة المفقودة: {nodeType}"
|
||||
},
|
||||
"swap_nodes": {
|
||||
"displayMessage": "يمكن استبدال بعض العُقد ببدائل",
|
||||
"toastMessageMany": "يمكن استبدال {count} نوع من العُقد ببدائل متوافقة.",
|
||||
"toastMessageOne": "استبدلها بـ {replacementNodeType} من لوحة الأخطاء.",
|
||||
"toastTitleMany": "يمكن استبدال العُقد",
|
||||
"toastTitleOne": "يمكن استبدال {nodeType}"
|
||||
}
|
||||
},
|
||||
"promptErrors": {
|
||||
"missing_node_type": {
|
||||
"desc": "نوع عقدة مفقود أو غير متوفر. قد يكون سير العمل تالفًا أو يحتاج إلى عقدة مخصصة.",
|
||||
@@ -1282,15 +1240,6 @@
|
||||
"toastMessageWithTypes": "مدخل {inputName} في {nodeName} يتطلب {expectedType}، لكن الإخراج المتصل هو {receivedType}.",
|
||||
"toastTitle": "اتصال غير صالح"
|
||||
},
|
||||
"unknown_validation_error": {
|
||||
"details": "{nodeName} أعاد خطأ تحقق غير معروف: {errorType}",
|
||||
"detailsWithRawDetails": "{nodeName} أعاد خطأ تحقق غير معروف ({errorType}): {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"message": "أعاد أحد العقد خطأ تحقق غير معروف لم يتم التعرف عليه من قبل ComfyUI.",
|
||||
"title": "فشل التحقق",
|
||||
"toastMessage": "{nodeName} أعاد خطأ تحقق غير معروف.",
|
||||
"toastTitle": "فشل التحقق"
|
||||
},
|
||||
"value_bigger_than_max": {
|
||||
"details": "{nodeName} لديه قيمة أعلى من الحد الأقصى لـ {inputName}.",
|
||||
"detailsWithValue": "القيمة {receivedValue} لـ {inputName} في {nodeName} أعلى من الحد الأقصى {maxValue}.",
|
||||
@@ -1338,9 +1287,12 @@
|
||||
"missingMedia": "بعض العقد تفتقد إلى مدخلات مطلوبة",
|
||||
"missingModels": "{count} نموذج مطلوب مفقود | {count} نماذج مطلوبة مفقودة",
|
||||
"missingNodes": "بعض العقد مفقودة وتحتاج إلى التثبيت",
|
||||
"multipleErrorCount": "تم العثور على {count} خطأ | تم العثور على {count} أخطاء",
|
||||
"multipleErrorsMessage": "يرجى حلها قبل تشغيل سير العمل.",
|
||||
"viewDetails": "عرض التفاصيل"
|
||||
"seeErrors": "عرض الأخطاء",
|
||||
"showMissingMedia": "عرض المدخلات المفقودة",
|
||||
"showMissingModels": "عرض النماذج المفقودة",
|
||||
"showMissingNodes": "عرض العقد المفقودة",
|
||||
"showSwapNodes": "عرض العقد البديلة",
|
||||
"swapNodes": "يمكن استبدال بعض العقد ببدائل"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "أعطِ ملاحظاتك",
|
||||
@@ -1424,11 +1376,6 @@
|
||||
"amount": "الكمية",
|
||||
"apply": "تطبيق",
|
||||
"architecture": "الهندسة المعمارية",
|
||||
"arrange": "ترتيب",
|
||||
"arrangeAsGrid": "ترتيب كشبكة",
|
||||
"arrangeHorizontally": "ترتيب أفقيًا",
|
||||
"arrangeSpacing": "تباعد الترتيب",
|
||||
"arrangeVertically": "ترتيب عموديًا",
|
||||
"asset": "{count} أصل | {count} أصول",
|
||||
"audioFailedToLoad": "فشل تحميل الصوت",
|
||||
"audioProgress": "تقدم الصوت",
|
||||
@@ -1494,7 +1441,6 @@
|
||||
"deleteImage": "حذف الصورة",
|
||||
"deprecated": "مهمل",
|
||||
"description": "الوصف",
|
||||
"details": "تفاصيل",
|
||||
"devOnly": "للمطورين فقط",
|
||||
"devices": "الأجهزة",
|
||||
"disableAll": "تعطيل الكل",
|
||||
@@ -1584,7 +1530,6 @@
|
||||
"jobIdCopied": "تم نسخ معرف المهمة إلى الحافظة",
|
||||
"keybinding": "اختصار لوحة المفاتيح",
|
||||
"keybindingAlreadyExists": "الاختصار موجود بالفعل في",
|
||||
"keybindingListAriaLabel": "اختصارات لوحة المفاتيح: {combos}",
|
||||
"keybindingPresets": {
|
||||
"default": "الإعداد المسبق الافتراضي",
|
||||
"deletePreset": "حذف الإعداد المسبق",
|
||||
@@ -1635,7 +1580,6 @@
|
||||
"mostRelevant": "الأكثر صلة",
|
||||
"multiSelectDropdown": "قائمة منسدلة متعددة الاختيار",
|
||||
"nMoreKeybindings": "+ {count} المزيد",
|
||||
"nMoreKeybindingsCompact": "+ {count}",
|
||||
"name": "الاسم",
|
||||
"newFolder": "مجلد جديد",
|
||||
"next": "التالي",
|
||||
@@ -2837,6 +2781,7 @@
|
||||
"replacedNode": "تم استبدال العقدة: {nodeType}",
|
||||
"selectAll": "تحديد الكل",
|
||||
"skipForNow": "تخطي الآن",
|
||||
"swapNodesGuide": "يمكن استبدال العقد التالية تلقائيًا ببدائل متوافقة.",
|
||||
"swapNodesTitle": "تبديل العقد",
|
||||
"unknownNode": "غير معروف",
|
||||
"willBeReplacedBy": "سيتم استبدال هذه العقدة بـ:"
|
||||
@@ -3008,7 +2953,6 @@
|
||||
"errorHelp": "للمزيد من المساعدة، {github} أو {support}",
|
||||
"errorHelpGithub": "إرسال مشكلة على GitHub",
|
||||
"errorHelpSupport": "تواصل مع الدعم الفني",
|
||||
"errorLog": "سجل الأخطاء",
|
||||
"errors": "الأخطاء",
|
||||
"executionErrorOccurred": "حدث خطأ أثناء التنفيذ. تحقق من علامة تبويب الأخطاء لمزيد من التفاصيل.",
|
||||
"fallbackGroupTitle": "مجموعة",
|
||||
@@ -3043,12 +2987,10 @@
|
||||
"hideAdvancedShort": "إخفاء الخيارات المتقدمة",
|
||||
"hideInput": "إخفاء المدخل",
|
||||
"info": "معلومات",
|
||||
"infoFor": "معلومات عن {item}",
|
||||
"inputs": "المدخلات",
|
||||
"inputsNone": "لا توجد مدخلات",
|
||||
"inputsNoneTooltip": "العقدة ليس لديها مدخلات",
|
||||
"locateNode": "تحديد موقع العقدة على اللوحة",
|
||||
"locateNodeFor": "تحديد موقع {item}",
|
||||
"missingMedia": {
|
||||
"audio": "الصوتيات",
|
||||
"cancelSelection": "إلغاء الاختيار",
|
||||
@@ -3100,6 +3042,7 @@
|
||||
},
|
||||
"missingNodePacks": {
|
||||
"applyChanges": "تطبيق التغييرات",
|
||||
"cloudMessage": "يتطلب سير العمل هذا عقدًا مخصصة غير متوفرة بعد على Comfy Cloud.",
|
||||
"collapse": "طي",
|
||||
"expand": "توسيع",
|
||||
"installAll": "تثبيت الكل",
|
||||
@@ -3107,6 +3050,7 @@
|
||||
"installed": "تم التثبيت",
|
||||
"installing": "جارٍ التثبيت...",
|
||||
"ossManagerDisabledHint": "لتثبيت العقد المفقودة، قم أولاً بتشغيل {pipCmd} في بيئة بايثون الخاصة بك لتثبيت مدير العقد، ثم أعد تشغيل ComfyUI مع العلم {flag}.",
|
||||
"ossMessage": "يستخدم سير العمل هذا عقدًا مخصصة لم تقم بتثبيتها بعد.",
|
||||
"searchInManager": "البحث في مدير العقد",
|
||||
"title": "حزم العقد المفقودة",
|
||||
"unknownPack": "حزمة غير معروفة",
|
||||
|
||||
@@ -619,31 +619,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BriaVideoGreenScreen": {
|
||||
"description": "استبدل خلفية الفيديو بشاشة كروما صلبة باستخدام Bria.",
|
||||
"display_name": "Bria فيديو الشاشة الخضراء",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"green_shade": {
|
||||
"name": "درجة اللون الأخضر",
|
||||
"tooltip": "درجة كروما صلبة توضع خلف المقدمة: broadcast_green (#00B140)، chroma_green (#00FF00)، أو blue_screen (#0000FF)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "تتحكم البذرة فيما إذا كان يجب إعادة تشغيل العقدة؛ النتائج غير حتمية بغض النظر عن البذرة."
|
||||
},
|
||||
"video": {
|
||||
"name": "فيديو"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2FirstLastFrameNode": {
|
||||
"description": "إنشاء فيديو باستخدام Seedance 2.0 من صورة الإطار الأول وصورة الإطار الأخير (اختياري).",
|
||||
"display_name": "ByteDance Seedance 2.0 من الإطار الأول/الأخير إلى فيديو",
|
||||
@@ -1876,10 +1851,6 @@
|
||||
"0": {
|
||||
"name": "قيمة RGB عددية",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "سداسي عشري",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4026,9 +3997,6 @@
|
||||
"description": "إزالة الكائن المحدد بالقناع من الصورة وإعادة بناء الخلفية. قم برسم القناع فوق ما تريد مسحه.",
|
||||
"display_name": "Flux مسح الصورة",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"dilate_pixels": {
|
||||
"name": "توسيع_البكسلات",
|
||||
"tooltip": "يوسع حدود القناع لضمان تغطية حواف الكائن بشكل نظيف."
|
||||
@@ -4039,10 +4007,6 @@
|
||||
"mask": {
|
||||
"name": "mask",
|
||||
"tooltip": "المناطق البيضاء تُزال؛ المناطق السوداء تُحفظ."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "البذرة العشوائية المستخدمة لإنشاء الضوضاء."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -4786,14 +4750,6 @@
|
||||
"system_prompt": {
|
||||
"name": "موجه النظام",
|
||||
"tooltip": "تعليمات أساسية تحدد سلوك الذكاء الاصطناعي."
|
||||
},
|
||||
"temperature": {
|
||||
"name": "درجة العشوائية",
|
||||
"tooltip": "تتحكم في العشوائية أثناء التوليد. قيمة أقل تعني تركيز/حتمية أكبر."
|
||||
},
|
||||
"top_p": {
|
||||
"name": "top_p",
|
||||
"tooltip": "عتبة أخذ العينات النواة. قيمة أقل تعني تركيز أكبر، وقيمة أعلى تعني تنوع أكبر."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -4855,48 +4811,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNodeV2": {
|
||||
"description": "توليد ردود نصية باستخدام نماذج Gemini من Google. قدم مطالبة نصية، ويمكنك أيضًا إضافة صورة أو مقطع صوتي أو فيديو أو ملف كمدخل متعدد الوسائط.",
|
||||
"display_name": "Google Gemini",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"model": {
|
||||
"name": "النموذج",
|
||||
"tooltip": "نموذج Gemini المستخدم لتوليد الرد."
|
||||
},
|
||||
"model_max_output_tokens": {
|
||||
"name": "الحد الأقصى للرموز الناتجة"
|
||||
},
|
||||
"model_temperature": {
|
||||
"name": "درجة العشوائية"
|
||||
},
|
||||
"model_thinking_level": {
|
||||
"name": "مستوى التفكير"
|
||||
},
|
||||
"model_top_p": {
|
||||
"name": "top_p"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "المطالبة",
|
||||
"tooltip": "إدخال نصي للنموذج. أضف تعليمات مفصلة أو أسئلة أو سياق."
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "بذرة أخذ العينات. ضعها على 0 لبذرة عشوائية. لا يمكن ضمان إخراج حتمي."
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "تعليمات النظام",
|
||||
"tooltip": "تعليمات أساسية تحدد سلوك النموذج."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GenerateTracks": {
|
||||
"display_name": "توليد المسارات",
|
||||
"inputs": {
|
||||
@@ -13811,15 +13725,15 @@
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "نموذج ثلاثي الأبعاد",
|
||||
"tooltip": "ملف نموذج ثلاثي الأبعاد من عقدة ثلاثية الأبعاد سابقة."
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "model_3d_info"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "حالة نافذة العرض"
|
||||
"model_file": {
|
||||
"name": "model_file",
|
||||
"tooltip": "ملف نموذج ثلاثي الأبعاد من عقدة ثلاثية الأبعاد سابقة."
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
@@ -13874,52 +13788,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewGaussianSplat": {
|
||||
"display_name": "معاينة Splat",
|
||||
"inputs": {
|
||||
"camera_info": {
|
||||
"name": "معلومات_الكاميرا"
|
||||
},
|
||||
"height": {
|
||||
"name": "الارتفاع"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "نموذج_ثلاثي_الأبعاد",
|
||||
"tooltip": "ملف Gaussian Splat ثلاثي الأبعاد."
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "معلومات_النموذج_ثلاثي_الأبعاد"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "حالة_منفذ_العرض"
|
||||
},
|
||||
"width": {
|
||||
"name": "العرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "نموذج_ثلاثي_الأبعاد",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "معلومات_النموذج_ثلاثي_الأبعاد",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "معلومات_الكاميرا",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "العرض",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "الارتفاع",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewImage": {
|
||||
"description": "يحفظ الصور المدخلة في دليل مخرجات ComfyUI الخاص بك.",
|
||||
"display_name": "معاينة الصورة",
|
||||
@@ -13929,52 +13797,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewPointCloud": {
|
||||
"display_name": "معاينة سحابة النقاط",
|
||||
"inputs": {
|
||||
"camera_info": {
|
||||
"name": "معلومات_الكاميرا"
|
||||
},
|
||||
"height": {
|
||||
"name": "الارتفاع"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "نموذج_ثلاثي_الأبعاد",
|
||||
"tooltip": "ملف سحابة النقاط (.ply)"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "معلومات_النموذج_ثلاثي_الأبعاد"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "حالة_منفذ_العرض"
|
||||
},
|
||||
"width": {
|
||||
"name": "العرض"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "نموذج_ثلاثي_الأبعاد",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "معلومات_النموذج_ثلاثي_الأبعاد",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "معلومات_الكاميرا",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "العرض",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "الارتفاع",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PrimitiveBoolean": {
|
||||
"display_name": "منطقي",
|
||||
"inputs": {
|
||||
@@ -15222,10 +15044,6 @@
|
||||
"megapixels": {
|
||||
"name": "الميغابكسل",
|
||||
"tooltip": "إجمالي الميغابكسل المستهدف. ١.٠ ميغابكسل ≈ ١٠٢٤×١٠٢٤ للبُعد المربع."
|
||||
},
|
||||
"multiple": {
|
||||
"name": "مضاعف",
|
||||
"tooltip": "أقرب مضاعف للنتيجة لتعيين الدقة المختارة عليه."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -16631,27 +16449,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioAdvanced": {
|
||||
"description": "يحفظ الصوت المُدخل في مجلد إخراج ComfyUI الخاص بك.",
|
||||
"display_name": "حفظ الصوت (متقدم)",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "الصوت",
|
||||
"tooltip": "الصوت المراد حفظه."
|
||||
},
|
||||
"audioUI": {
|
||||
"name": "واجهة_الصوت"
|
||||
},
|
||||
"filename_prefix": {
|
||||
"name": "بادئة_اسم_الملف",
|
||||
"tooltip": "بادئة اسم الملف للحفظ. يمكن أن تتضمن رموز تنسيق مثل %date:yyyy-MM-dd%."
|
||||
},
|
||||
"format": {
|
||||
"name": "الصيغة",
|
||||
"tooltip": "صيغة الملف التي سيتم حفظ الصوت بها."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioMP3": {
|
||||
"display_name": "حفظ الصوت (MP3)",
|
||||
"inputs": {
|
||||
|
||||
@@ -83,8 +83,6 @@
|
||||
"resetToDefault": "Reset to default",
|
||||
"removeKeybinding": "Remove keybinding",
|
||||
"nMoreKeybindings": "+ {count} more",
|
||||
"nMoreKeybindingsCompact": "+ {count}",
|
||||
"keybindingListAriaLabel": "Keybindings: {combos}",
|
||||
"customizeFolder": "Customize Folder",
|
||||
"icon": "Icon",
|
||||
"color": "Color",
|
||||
@@ -270,7 +268,6 @@
|
||||
"title": "Title",
|
||||
"edit": "Edit",
|
||||
"copy": "Copy",
|
||||
"details": "Details",
|
||||
"copyJobId": "Copy Job ID",
|
||||
"copied": "Copied",
|
||||
"relativeTime": {
|
||||
@@ -353,11 +350,6 @@
|
||||
"nodeSlotsError": "Node Slots Error",
|
||||
"nodeWidgetsError": "Node Widgets Error",
|
||||
"frameNodes": "Frame Nodes",
|
||||
"arrange": "Arrange",
|
||||
"arrangeVertically": "Arrange vertically",
|
||||
"arrangeHorizontally": "Arrange horizontally",
|
||||
"arrangeAsGrid": "Arrange as grid",
|
||||
"arrangeSpacing": "Arrangement spacing",
|
||||
"listening": "Listening...",
|
||||
"ready": "Ready",
|
||||
"play": "Play",
|
||||
@@ -1806,9 +1798,7 @@
|
||||
"FILE_3D_KSPLAT": "FILE_3D_KSPLAT",
|
||||
"FILE_3D_OBJ": "FILE_3D_OBJ",
|
||||
"FILE_3D_PLY": "FILE_3D_PLY",
|
||||
"FILE_3D_POINT_CLOUD_ANY": "FILE_3D_POINT_CLOUD_ANY",
|
||||
"FILE_3D_SPLAT": "FILE_3D_SPLAT",
|
||||
"FILE_3D_SPLAT_ANY": "FILE_3D_SPLAT_ANY",
|
||||
"FILE_3D_SPZ": "FILE_3D_SPZ",
|
||||
"FILE_3D_STL": "FILE_3D_STL",
|
||||
"FILE_3D_USDZ": "FILE_3D_USDZ",
|
||||
@@ -3559,7 +3549,6 @@
|
||||
"parameters": "Parameters",
|
||||
"nodes": "Nodes",
|
||||
"info": "Info",
|
||||
"infoFor": "Info for {item}",
|
||||
"color": "Node color",
|
||||
"pinned": "Pinned",
|
||||
"bypass": "Bypass",
|
||||
@@ -3579,7 +3568,6 @@
|
||||
"hideInput": "Hide input",
|
||||
"showInput": "Show input",
|
||||
"locateNode": "Locate node on canvas",
|
||||
"locateNodeFor": "Locate {item}",
|
||||
"favorites": "FAVORITED INPUTS",
|
||||
"favoritesNone": "NO FAVORITED INPUTS",
|
||||
"favoritesNoneTooltip": "Star widgets to quickly access them without selecting nodes",
|
||||
@@ -3613,7 +3601,6 @@
|
||||
"errors": "Errors",
|
||||
"noErrors": "No errors",
|
||||
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
|
||||
"errorLog": "Error log",
|
||||
"findOnGithubTooltip": "Search GitHub issues for related problems",
|
||||
"getHelpTooltip": "Report this error and we'll help you resolve it",
|
||||
"enterSubgraph": "Enter subgraph",
|
||||
@@ -3818,15 +3805,6 @@
|
||||
"toastTitle": "Invalid input",
|
||||
"toastMessage": "{nodeName} rejected the value for {inputName}."
|
||||
},
|
||||
"unknown_validation_error": {
|
||||
"title": "Validation failed",
|
||||
"message": "A node returned a validation error ComfyUI does not recognize.",
|
||||
"details": "{nodeName} returned an unrecognized validation error: {errorType}",
|
||||
"detailsWithRawDetails": "{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"toastTitle": "Validation failed",
|
||||
"toastMessage": "{nodeName} returned an unrecognized validation error."
|
||||
},
|
||||
"exception_during_inner_validation": {
|
||||
"title": "Validation failed",
|
||||
"message": "The workflow couldn't validate a connected node.",
|
||||
|
||||
@@ -619,31 +619,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BriaVideoGreenScreen": {
|
||||
"display_name": "Bria Video Green Screen",
|
||||
"description": "Replace a video's background with a solid chroma-key screen using Bria.",
|
||||
"inputs": {
|
||||
"video": {
|
||||
"name": "video"
|
||||
},
|
||||
"green_shade": {
|
||||
"name": "green_shade",
|
||||
"tooltip": "Solid chroma-key shade applied behind the foreground: broadcast_green (#00B140), chroma_green (#00FF00), or blue_screen (#0000FF)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed controls whether the node should re-run; results are non-deterministic regardless of seed."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2FirstLastFrameNode": {
|
||||
"display_name": "ByteDance Seedance 2.0 First-Last-Frame to Video",
|
||||
"description": "Generate video using Seedance 2.0 from a first frame image and optional last frame image.",
|
||||
@@ -1324,7 +1299,7 @@
|
||||
},
|
||||
"CFGOverride": {
|
||||
"display_name": "CFG Override",
|
||||
"description": "Override cfg to a fixed value over a [start, end] percent (sigma) range. With multiple overrides, the one nearest the sampler wins on overlap.",
|
||||
"description": "Override cfg to a fixed value over a [start, end] percent slice of the steps. With multiple overrides, the one nearest the sampler wins on overlap.",
|
||||
"inputs": {
|
||||
"model": {
|
||||
"name": "model"
|
||||
@@ -1865,8 +1840,8 @@
|
||||
}
|
||||
},
|
||||
"ColorToRGBInt": {
|
||||
"display_name": "Color Picker",
|
||||
"description": "Return a color RGB integer value and hexadecimal representation.",
|
||||
"display_name": "Color to RGB Int",
|
||||
"description": "Convert a color to a RGB integer value.",
|
||||
"inputs": {
|
||||
"color": {
|
||||
"name": "color"
|
||||
@@ -1876,10 +1851,6 @@
|
||||
"0": {
|
||||
"name": "rgb_int",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "hex",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4036,13 +4007,6 @@
|
||||
"dilate_pixels": {
|
||||
"name": "dilate_pixels",
|
||||
"tooltip": "Expands the mask boundaries to ensure clean coverage of the object's edges."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "The random seed used for creating the noise."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -4667,14 +4631,6 @@
|
||||
"name": "system_prompt",
|
||||
"tooltip": "Foundational instructions that dictate an AI's behavior."
|
||||
},
|
||||
"temperature": {
|
||||
"name": "temperature",
|
||||
"tooltip": "Controls randomness in generation. Lower is more focused/deterministic."
|
||||
},
|
||||
"top_p": {
|
||||
"name": "top_p",
|
||||
"tooltip": "Nucleus sampling threshold. Lower is more focused, higher more diverse."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
@@ -4747,48 +4703,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNodeV2": {
|
||||
"display_name": "Google Gemini",
|
||||
"description": "Generate text responses with Google's Gemini models. Provide a text prompt and, optionally, one or more images, audio clips, videos, or files as multimodal context.",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Text input to the model. Include detailed instructions, questions, or context."
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "The Gemini model used to generate the response."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Seed for sampling. Set to 0 for a random seed. Deterministic output isn't guaranteed."
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "system_prompt",
|
||||
"tooltip": "Foundational instructions that dictate the model's behavior."
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model_max_output_tokens": {
|
||||
"name": "max_output_tokens"
|
||||
},
|
||||
"model_temperature": {
|
||||
"name": "temperature"
|
||||
},
|
||||
"model_thinking_level": {
|
||||
"name": "thinking_level"
|
||||
},
|
||||
"model_top_p": {
|
||||
"name": "top_p"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GenerateTracks": {
|
||||
"display_name": "GenerateTracks",
|
||||
"inputs": {
|
||||
@@ -13805,12 +13719,12 @@
|
||||
"Preview3DAdvanced": {
|
||||
"display_name": "Preview 3D (Advanced)",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"model_file": {
|
||||
"name": "model_file",
|
||||
"tooltip": "3D model file from an upstream 3D node."
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "viewport_state"
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
@@ -13818,24 +13732,24 @@
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "model_3d_info"
|
||||
},
|
||||
"camera_info": {
|
||||
"name": "camera_info"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "model_3d_info"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model_3d",
|
||||
"name": "model_file",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "model_3d_info",
|
||||
"name": "camera_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "camera_info",
|
||||
"name": "model_3d_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
@@ -13874,52 +13788,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewGaussianSplat": {
|
||||
"display_name": "Preview Splat",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "A gaussian splat 3D file."
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "viewport_state"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "model_3d_info"
|
||||
},
|
||||
"camera_info": {
|
||||
"name": "camera_info"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "model_3d_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "camera_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "width",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "height",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewImage": {
|
||||
"display_name": "Preview Image",
|
||||
"description": "Saves the input images to your ComfyUI output directory.",
|
||||
@@ -13929,52 +13797,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewPointCloud": {
|
||||
"display_name": "Preview Point Cloud",
|
||||
"inputs": {
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "Point cloud file (.ply)"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "viewport_state"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "model_3d_info"
|
||||
},
|
||||
"camera_info": {
|
||||
"name": "camera_info"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "model_3d_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "camera_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "width",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "height",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PrimitiveBoolean": {
|
||||
"display_name": "Boolean",
|
||||
"inputs": {
|
||||
@@ -15193,21 +15015,17 @@
|
||||
},
|
||||
"megapixels": {
|
||||
"name": "megapixels",
|
||||
"tooltip": "Target total megapixels. 1.0 MP ≈ 1024x1024 for square."
|
||||
},
|
||||
"multiple": {
|
||||
"name": "multiple",
|
||||
"tooltip": "Nearest multiple of the result to set the selected resolution to."
|
||||
"tooltip": "Target total megapixels. 1.0 MP ≈ 1024×1024 for square."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "width",
|
||||
"tooltip": "Calculated width in pixels multiplied by the selected multiple."
|
||||
"tooltip": "Calculated width in pixels (multiple of 8)."
|
||||
},
|
||||
"1": {
|
||||
"name": "height",
|
||||
"tooltip": "Calculated height in pixels multiplied by the selected multiple."
|
||||
"tooltip": "Calculated height in pixels (multiple of 8)."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -16360,7 +16178,7 @@
|
||||
}
|
||||
},
|
||||
"SaveAudio": {
|
||||
"display_name": "Save Audio (FLAC) (Deprecated)",
|
||||
"display_name": "Save Audio (FLAC)",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
@@ -16373,29 +16191,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioAdvanced": {
|
||||
"display_name": "Save Audio (Advanced)",
|
||||
"description": "Saves the input audio to your ComfyUI output directory.",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "The audio to save."
|
||||
},
|
||||
"filename_prefix": {
|
||||
"name": "filename_prefix",
|
||||
"tooltip": "The prefix for the file to save. May include formatting tokens such as %date:yyyy-MM-dd%."
|
||||
},
|
||||
"format": {
|
||||
"name": "format",
|
||||
"tooltip": "The file format in which to save the audio."
|
||||
},
|
||||
"audioUI": {
|
||||
"name": "audioUI"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioMP3": {
|
||||
"display_name": "Save Audio (MP3) (Deprecated)",
|
||||
"display_name": "Save Audio (MP3)",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
@@ -16412,7 +16209,7 @@
|
||||
}
|
||||
},
|
||||
"SaveAudioOpus": {
|
||||
"display_name": "Save Audio (Opus) (Deprecated)",
|
||||
"display_name": "Save Audio (Opus)",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
|
||||
@@ -832,9 +832,7 @@
|
||||
"FILE_3D_KSPLAT": "FILE_3D_KSPLAT",
|
||||
"FILE_3D_OBJ": "ARCHIVO_3D_OBJ",
|
||||
"FILE_3D_PLY": "FILE_3D_PLY",
|
||||
"FILE_3D_POINT_CLOUD_ANY": "FILE_3D_POINT_CLOUD_ANY",
|
||||
"FILE_3D_SPLAT": "FILE_3D_SPLAT",
|
||||
"FILE_3D_SPLAT_ANY": "FILE_3D_SPLAT_ANY",
|
||||
"FILE_3D_SPZ": "FILE_3D_SPZ",
|
||||
"FILE_3D_STL": "ARCHIVO_3D_STL",
|
||||
"FILE_3D_USDZ": "ARCHIVO_3D_USDZ",
|
||||
@@ -948,46 +946,6 @@
|
||||
"inputName": "entrada desconocida",
|
||||
"nodeName": "Este nodo"
|
||||
},
|
||||
"missingErrors": {
|
||||
"missing_media": {
|
||||
"displayMessage": "Una entrada de medio requerida no tiene archivo seleccionado.",
|
||||
"toastMessageMany": "Selecciona las entradas de medio faltantes antes de ejecutar este flujo de trabajo.",
|
||||
"toastMessageWithNode": "{nodeName} no tiene el archivo de medio requerido.",
|
||||
"toastTitleMany": "Faltan entradas de medio",
|
||||
"toastTitleOne": "Falta entrada de medio"
|
||||
},
|
||||
"missing_model": {
|
||||
"displayMessageCloud": "Importa un modelo o abre el nodo para reemplazarlo.",
|
||||
"displayMessageOss": "Descarga un modelo o abre el nodo para reemplazarlo.",
|
||||
"toastMessageManyCloud": "Algunos modelos no son compatibles. Elige otros.",
|
||||
"toastMessageManyOss": "Faltan {count} archivos de modelo.",
|
||||
"toastMessageOneCloud": "Este modelo no es compatible. Elige otro.",
|
||||
"toastMessageOneOss": "{nodeName} no tiene el archivo de modelo requerido.",
|
||||
"toastTitleMany": "Modelos faltantes",
|
||||
"toastTitleManyCloud": "Modelos no disponibles en la nube",
|
||||
"toastTitleOneCloud": "{modelName} no está disponible en la nube",
|
||||
"toastTitleOneOss": "{modelName} falta"
|
||||
},
|
||||
"missing_node": {
|
||||
"displayMessageCloud": "Los nodos personalizados requeridos no son compatibles en la nube. Reemplázalos por nodos compatibles.",
|
||||
"displayMessageOss": "Instala los paquetes faltantes para usar este flujo de trabajo.",
|
||||
"toastMessageManyCloud": "Este flujo de trabajo utiliza nodos que no son compatibles en la nube.",
|
||||
"toastMessageManyOss": "{count} nodos requieren paquetes de nodos faltantes.",
|
||||
"toastMessageOneCloud": "Este nodo no es compatible en la nube.",
|
||||
"toastMessageOneOss": "Este flujo de trabajo utiliza un nodo personalizado que no está instalado. Instálalo desde el registro o reemplaza el nodo.",
|
||||
"toastTitleManyCloud": "Nodos no disponibles en la nube",
|
||||
"toastTitleManyOss": "Nodos faltantes",
|
||||
"toastTitleOneCloud": "{nodeType} no está disponible en la nube",
|
||||
"toastTitleOneOss": "Nodo faltante: {nodeType}"
|
||||
},
|
||||
"swap_nodes": {
|
||||
"displayMessage": "Algunos nodos pueden ser reemplazados por alternativas",
|
||||
"toastMessageMany": "{count} tipos de nodos pueden ser reemplazados por alternativas compatibles.",
|
||||
"toastMessageOne": "Reemplázalo por {replacementNodeType} desde el panel de errores.",
|
||||
"toastTitleMany": "Nodos pueden ser reemplazados",
|
||||
"toastTitleOne": "{nodeType} puede ser reemplazado"
|
||||
}
|
||||
},
|
||||
"promptErrors": {
|
||||
"missing_node_type": {
|
||||
"desc": "Falta un tipo de nodo o no está disponible. El flujo de trabajo puede estar dañado o requerir un nodo personalizado.",
|
||||
@@ -1282,15 +1240,6 @@
|
||||
"toastMessageWithTypes": "La entrada {inputName} de {nodeName} espera {expectedType}, pero la salida conectada es {receivedType}.",
|
||||
"toastTitle": "Conexión inválida"
|
||||
},
|
||||
"unknown_validation_error": {
|
||||
"details": "{nodeName} devolvió un error de validación no reconocido: {errorType}",
|
||||
"detailsWithRawDetails": "{nodeName} devolvió un error de validación no reconocido ({errorType}): {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"message": "Un nodo devolvió un error de validación que ComfyUI no reconoce.",
|
||||
"title": "La validación falló",
|
||||
"toastMessage": "{nodeName} devolvió un error de validación no reconocido.",
|
||||
"toastTitle": "La validación falló"
|
||||
},
|
||||
"value_bigger_than_max": {
|
||||
"details": "{nodeName} tiene un valor por encima del máximo para {inputName}.",
|
||||
"detailsWithValue": "El valor {receivedValue} para la entrada {inputName} de {nodeName} está por encima del máximo {maxValue}.",
|
||||
@@ -1338,9 +1287,12 @@
|
||||
"missingMedia": "A algunos nodos les faltan entradas requeridas",
|
||||
"missingModels": "Falta {count} modelo requerido | Faltan {count} modelos requeridos",
|
||||
"missingNodes": "Faltan algunos nodos y necesitan ser instalados",
|
||||
"multipleErrorCount": "{count} error encontrado | {count} errores encontrados",
|
||||
"multipleErrorsMessage": "Resuélvelos antes de ejecutar el flujo de trabajo.",
|
||||
"viewDetails": "Ver detalles"
|
||||
"seeErrors": "Ver errores",
|
||||
"showMissingMedia": "Mostrar entradas faltantes",
|
||||
"showMissingModels": "Mostrar modelos faltantes",
|
||||
"showMissingNodes": "Mostrar nodos faltantes",
|
||||
"showSwapNodes": "Mostrar nodos intercambiables",
|
||||
"swapNodes": "Algunos nodos pueden ser reemplazados por alternativas"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "Dar opinión",
|
||||
@@ -1424,11 +1376,6 @@
|
||||
"amount": "Cantidad",
|
||||
"apply": "Aplicar",
|
||||
"architecture": "Arquitectura",
|
||||
"arrange": "Organizar",
|
||||
"arrangeAsGrid": "Organizar como cuadrícula",
|
||||
"arrangeHorizontally": "Organizar horizontalmente",
|
||||
"arrangeSpacing": "Espaciado de organización",
|
||||
"arrangeVertically": "Organizar verticalmente",
|
||||
"asset": "{count} recurso | {count} recursos",
|
||||
"audioFailedToLoad": "No se pudo cargar el audio",
|
||||
"audioProgress": "Progreso de audio",
|
||||
@@ -1494,7 +1441,6 @@
|
||||
"deleteImage": "Eliminar imagen",
|
||||
"deprecated": "DEPR",
|
||||
"description": "Descripción",
|
||||
"details": "Detalles",
|
||||
"devOnly": "DEV",
|
||||
"devices": "Dispositivos",
|
||||
"disableAll": "Deshabilitar todo",
|
||||
@@ -1584,7 +1530,6 @@
|
||||
"jobIdCopied": "ID de trabajo copiado al portapapeles",
|
||||
"keybinding": "Combinación de teclas",
|
||||
"keybindingAlreadyExists": "La combinación de teclas ya existe en",
|
||||
"keybindingListAriaLabel": "Combinaciones de teclas: {combos}",
|
||||
"keybindingPresets": {
|
||||
"default": "Preajuste predeterminado",
|
||||
"deletePreset": "Eliminar preajuste",
|
||||
@@ -1635,7 +1580,6 @@
|
||||
"mostRelevant": "Más relevante",
|
||||
"multiSelectDropdown": "Menú desplegable de selección múltiple",
|
||||
"nMoreKeybindings": "+ {count} más",
|
||||
"nMoreKeybindingsCompact": "+ {count}",
|
||||
"name": "Nombre",
|
||||
"newFolder": "Nueva carpeta",
|
||||
"next": "Siguiente",
|
||||
@@ -2837,6 +2781,7 @@
|
||||
"replacedNode": "Nodo reemplazado: {nodeType}",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"skipForNow": "Omitir por ahora",
|
||||
"swapNodesGuide": "Los siguientes nodos pueden ser reemplazados automáticamente por alternativas compatibles.",
|
||||
"swapNodesTitle": "Intercambiar nodos",
|
||||
"unknownNode": "Desconocido",
|
||||
"willBeReplacedBy": "Este nodo será reemplazado por:"
|
||||
@@ -3008,7 +2953,6 @@
|
||||
"errorHelp": "Para más ayuda, {github} o {support}",
|
||||
"errorHelpGithub": "envía un issue en GitHub",
|
||||
"errorHelpSupport": "contacta con nuestro soporte",
|
||||
"errorLog": "Registro de errores",
|
||||
"errors": "Errores",
|
||||
"executionErrorOccurred": "Ocurrió un error durante la ejecución. Consulta la pestaña de Errores para más detalles.",
|
||||
"fallbackGroupTitle": "Grupo",
|
||||
@@ -3043,12 +2987,10 @@
|
||||
"hideAdvancedShort": "Ocultar avanzado",
|
||||
"hideInput": "Ocultar entrada",
|
||||
"info": "Información",
|
||||
"infoFor": "Información para {item}",
|
||||
"inputs": "ENTRADAS",
|
||||
"inputsNone": "SIN ENTRADAS",
|
||||
"inputsNoneTooltip": "El nodo no tiene entradas",
|
||||
"locateNode": "Localizar nodo en el lienzo",
|
||||
"locateNodeFor": "Localizar {item}",
|
||||
"missingMedia": {
|
||||
"audio": "Audio",
|
||||
"cancelSelection": "Cancelar selección",
|
||||
@@ -3100,6 +3042,7 @@
|
||||
},
|
||||
"missingNodePacks": {
|
||||
"applyChanges": "Aplicar cambios",
|
||||
"cloudMessage": "Este flujo de trabajo requiere nodos personalizados que aún no están disponibles en Comfy Cloud.",
|
||||
"collapse": "Colapsar",
|
||||
"expand": "Expandir",
|
||||
"installAll": "Instalar todo",
|
||||
@@ -3107,6 +3050,7 @@
|
||||
"installed": "Instalado",
|
||||
"installing": "Instalando...",
|
||||
"ossManagerDisabledHint": "Para instalar los nodos que faltan, primero ejecuta {pipCmd} en tu entorno de Python para instalar Node Manager, luego reinicia ComfyUI con la bandera {flag}.",
|
||||
"ossMessage": "Este flujo de trabajo utiliza nodos personalizados que aún no has instalado.",
|
||||
"searchInManager": "Buscar en el Gestor de Nodos",
|
||||
"title": "Paquetes de nodos faltantes",
|
||||
"unknownPack": "Paquete desconocido",
|
||||
|
||||
@@ -619,31 +619,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BriaVideoGreenScreen": {
|
||||
"description": "Reemplaza el fondo de un video con una pantalla sólida de croma usando Bria.",
|
||||
"display_name": "Bria Video Green Screen",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"green_shade": {
|
||||
"name": "green_shade",
|
||||
"tooltip": "Tono sólido de croma aplicado detrás del primer plano: broadcast_green (#00B140), chroma_green (#00FF00) o blue_screen (#0000FF)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "La semilla controla si el nodo debe ejecutarse de nuevo; los resultados son no deterministas independientemente de la semilla."
|
||||
},
|
||||
"video": {
|
||||
"name": "video"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2FirstLastFrameNode": {
|
||||
"description": "Genera un video usando Seedance 2.0 a partir de una imagen del primer fotograma y, opcionalmente, una imagen del último fotograma.",
|
||||
"display_name": "ByteDance Seedance 2.0 Primer-Último Fotograma a Video",
|
||||
@@ -1876,10 +1851,6 @@
|
||||
"0": {
|
||||
"name": "rgb_int",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "hex",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4026,9 +3997,6 @@
|
||||
"description": "Elimina el objeto enmascarado de una imagen y reconstruye el fondo. Pinta la máscara sobre lo que deseas borrar.",
|
||||
"display_name": "Flux Borrar imagen",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"dilate_pixels": {
|
||||
"name": "dilatar_pixeles",
|
||||
"tooltip": "Expande los bordes de la máscara para asegurar una cobertura limpia de los bordes del objeto."
|
||||
@@ -4039,10 +4007,6 @@
|
||||
"mask": {
|
||||
"name": "máscara",
|
||||
"tooltip": "Las áreas blancas se eliminan; las áreas negras se conservan."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "La semilla aleatoria utilizada para crear el ruido."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -4786,14 +4750,6 @@
|
||||
"system_prompt": {
|
||||
"name": "system_prompt",
|
||||
"tooltip": "Instrucciones fundamentales que dictan el comportamiento de la IA."
|
||||
},
|
||||
"temperature": {
|
||||
"name": "temperature",
|
||||
"tooltip": "Controla la aleatoriedad en la generación. Un valor más bajo es más enfocado/determinista."
|
||||
},
|
||||
"top_p": {
|
||||
"name": "top_p",
|
||||
"tooltip": "Umbral de muestreo por núcleo. Más bajo es más enfocado, más alto es más diverso."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -4855,48 +4811,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNodeV2": {
|
||||
"description": "Genera respuestas de texto con los modelos Gemini de Google. Proporciona un mensaje de texto y, opcionalmente, una o más imágenes, clips de audio, videos o archivos como contexto multimodal.",
|
||||
"display_name": "Google Gemini",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"tooltip": "El modelo Gemini utilizado para generar la respuesta."
|
||||
},
|
||||
"model_max_output_tokens": {
|
||||
"name": "max_output_tokens"
|
||||
},
|
||||
"model_temperature": {
|
||||
"name": "temperature"
|
||||
},
|
||||
"model_thinking_level": {
|
||||
"name": "thinking_level"
|
||||
},
|
||||
"model_top_p": {
|
||||
"name": "top_p"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Entrada de texto para el modelo. Incluye instrucciones detalladas, preguntas o contexto."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Semilla para el muestreo. Establece en 0 para una semilla aleatoria. No se garantiza una salida determinista."
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "system_prompt",
|
||||
"tooltip": "Instrucciones fundamentales que dictan el comportamiento del modelo."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GenerateTracks": {
|
||||
"display_name": "GenerateTracks",
|
||||
"inputs": {
|
||||
@@ -13811,15 +13725,15 @@
|
||||
"height": {
|
||||
"name": "alto"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "Archivo de modelo 3D de un nodo 3D anterior."
|
||||
"image": {
|
||||
"name": "imagen"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "info_modelo_3d"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "viewport_state"
|
||||
"model_file": {
|
||||
"name": "archivo_modelo",
|
||||
"tooltip": "Archivo de modelo 3D de un nodo 3D anterior."
|
||||
},
|
||||
"width": {
|
||||
"name": "ancho"
|
||||
@@ -13874,52 +13788,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewGaussianSplat": {
|
||||
"display_name": "Vista previa de Splat",
|
||||
"inputs": {
|
||||
"camera_info": {
|
||||
"name": "info_cámara"
|
||||
},
|
||||
"height": {
|
||||
"name": "alto"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "modelo_3d",
|
||||
"tooltip": "Un archivo 3D de gaussian splat."
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "info_modelo_3d"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "estado_de_vista"
|
||||
},
|
||||
"width": {
|
||||
"name": "ancho"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "modelo_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "info_modelo_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "info_cámara",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "ancho",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "alto",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewImage": {
|
||||
"description": "Guarda las imágenes de entrada en tu directorio de salida de ComfyUI.",
|
||||
"display_name": "Vista previa de imagen",
|
||||
@@ -13929,52 +13797,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewPointCloud": {
|
||||
"display_name": "Vista previa de nube de puntos",
|
||||
"inputs": {
|
||||
"camera_info": {
|
||||
"name": "info_cámara"
|
||||
},
|
||||
"height": {
|
||||
"name": "alto"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "modelo_3d",
|
||||
"tooltip": "Archivo de nube de puntos (.ply)"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "info_modelo_3d"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "estado_de_vista"
|
||||
},
|
||||
"width": {
|
||||
"name": "ancho"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "modelo_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "info_modelo_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "info_cámara",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "ancho",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "alto",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PrimitiveBoolean": {
|
||||
"display_name": "Booleano",
|
||||
"inputs": {
|
||||
@@ -15222,10 +15044,6 @@
|
||||
"megapixels": {
|
||||
"name": "megapíxeles",
|
||||
"tooltip": "Total de megapíxeles objetivo. 1.0 MP ≈ 1024×1024 para cuadrado."
|
||||
},
|
||||
"multiple": {
|
||||
"name": "múltiplo",
|
||||
"tooltip": "Múltiplo más cercano del resultado para establecer la resolución seleccionada."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -16631,27 +16449,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioAdvanced": {
|
||||
"description": "Guarda el audio de entrada en el directorio de salida de ComfyUI.",
|
||||
"display_name": "Guardar audio (avanzado)",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "El audio a guardar."
|
||||
},
|
||||
"audioUI": {
|
||||
"name": "audioUI"
|
||||
},
|
||||
"filename_prefix": {
|
||||
"name": "prefijo_nombre_archivo",
|
||||
"tooltip": "El prefijo para el archivo a guardar. Puede incluir tokens de formato como %date:yyyy-MM-dd%."
|
||||
},
|
||||
"format": {
|
||||
"name": "formato",
|
||||
"tooltip": "El formato de archivo en el que guardar el audio."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioMP3": {
|
||||
"display_name": "Guardar Audio (MP3)",
|
||||
"inputs": {
|
||||
|
||||
@@ -832,9 +832,7 @@
|
||||
"FILE_3D_KSPLAT": "FILE_3D_KSPLAT",
|
||||
"FILE_3D_OBJ": "FILE_3D_OBJ",
|
||||
"FILE_3D_PLY": "FILE_3D_PLY",
|
||||
"FILE_3D_POINT_CLOUD_ANY": "FILE_3D_POINT_CLOUD_ANY",
|
||||
"FILE_3D_SPLAT": "FILE_3D_SPLAT",
|
||||
"FILE_3D_SPLAT_ANY": "FILE_3D_SPLAT_ANY",
|
||||
"FILE_3D_SPZ": "FILE_3D_SPZ",
|
||||
"FILE_3D_STL": "FILE_3D_STL",
|
||||
"FILE_3D_USDZ": "FILE_3D_USDZ",
|
||||
@@ -948,46 +946,6 @@
|
||||
"inputName": "ورودی نامشخص",
|
||||
"nodeName": "این نود"
|
||||
},
|
||||
"missingErrors": {
|
||||
"missing_media": {
|
||||
"displayMessage": "یک ورودی رسانهای مورد نیاز بدون فایل انتخاب شده است.",
|
||||
"toastMessageMany": "لطفاً قبل از اجرای این workflow، ورودیهای رسانهای مفقود را انتخاب کنید.",
|
||||
"toastMessageWithNode": "{nodeName} به یک فایل رسانهای مورد نیاز دسترسی ندارد.",
|
||||
"toastTitleMany": "ورودیهای رسانهای مفقود",
|
||||
"toastTitleOne": "ورودی رسانهای مفقود"
|
||||
},
|
||||
"missing_model": {
|
||||
"displayMessageCloud": "یک مدل وارد کنید یا نود را باز کنید تا آن را جایگزین نمایید.",
|
||||
"displayMessageOss": "یک مدل دانلود کنید یا نود را باز کنید تا آن را جایگزین نمایید.",
|
||||
"toastMessageManyCloud": "برخی مدلها پشتیبانی نمیشوند. مدلهای دیگری انتخاب کنید.",
|
||||
"toastMessageManyOss": "{count} فایل مدل مفقود است.",
|
||||
"toastMessageOneCloud": "این مدل پشتیبانی نمیشود. مدل دیگری انتخاب کنید.",
|
||||
"toastMessageOneOss": "{nodeName} به یک فایل مدل مورد نیاز دسترسی ندارد.",
|
||||
"toastTitleMany": "مدلهای مفقود",
|
||||
"toastTitleManyCloud": "مدلها در Cloud در دسترس نیستند",
|
||||
"toastTitleOneCloud": "{modelName} در Cloud در دسترس نیست",
|
||||
"toastTitleOneOss": "{modelName} مفقود است"
|
||||
},
|
||||
"missing_node": {
|
||||
"displayMessageCloud": "نودهای سفارشی مورد نیاز در Cloud پشتیبانی نمیشوند. آنها را با نودهای پشتیبانیشده جایگزین کنید.",
|
||||
"displayMessageOss": "برای استفاده از این workflow، بستههای مورد نیاز را نصب کنید.",
|
||||
"toastMessageManyCloud": "این workflow از نودهایی استفاده میکند که در Cloud پشتیبانی نمیشوند.",
|
||||
"toastMessageManyOss": "{count} نود به بستههای نود مفقود نیاز دارند.",
|
||||
"toastMessageOneCloud": "این نود در Cloud پشتیبانی نمیشود.",
|
||||
"toastMessageOneOss": "این workflow از یک نود سفارشی استفاده میکند که نصب نشده است. آن را از رجیستری نصب یا نود را جایگزین کنید.",
|
||||
"toastTitleManyCloud": "نودها در Cloud در دسترس نیستند",
|
||||
"toastTitleManyOss": "نودهای مفقود",
|
||||
"toastTitleOneCloud": "{nodeType} در Cloud در دسترس نیست",
|
||||
"toastTitleOneOss": "نود مفقود: {nodeType}"
|
||||
},
|
||||
"swap_nodes": {
|
||||
"displayMessage": "برخی نودها را میتوان با گزینههای جایگزین تعویض کرد",
|
||||
"toastMessageMany": "{count} نوع نود را میتوان با گزینههای سازگار جایگزین کرد.",
|
||||
"toastMessageOne": "آن را با {replacementNodeType} از پنل خطا جایگزین کنید.",
|
||||
"toastTitleMany": "نودها قابل جایگزینی هستند",
|
||||
"toastTitleOne": "{nodeType} قابل جایگزینی است"
|
||||
}
|
||||
},
|
||||
"promptErrors": {
|
||||
"missing_node_type": {
|
||||
"desc": "یک نوع node وجود ندارد یا در دسترس نیست. ممکن است workflow خراب شده باشد یا به node سفارشی نیاز داشته باشد.",
|
||||
@@ -1282,15 +1240,6 @@
|
||||
"toastMessageWithTypes": "ورودی {inputName} در {nodeName} انتظار {expectedType} را دارد، اما خروجی متصل {receivedType} است.",
|
||||
"toastTitle": "اتصال نامعتبر"
|
||||
},
|
||||
"unknown_validation_error": {
|
||||
"details": "{nodeName} یک خطای اعتبارسنجی ناشناخته بازگرداند: {errorType}",
|
||||
"detailsWithRawDetails": "{nodeName} یک خطای اعتبارسنجی ناشناخته بازگرداند ({errorType}): {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"message": "یک node خطای اعتبارسنجی بازگرداند که توسط ComfyUI شناسایی نشد.",
|
||||
"title": "اعتبارسنجی ناموفق بود",
|
||||
"toastMessage": "{nodeName} یک خطای اعتبارسنجی ناشناخته بازگرداند.",
|
||||
"toastTitle": "اعتبارسنجی ناموفق بود"
|
||||
},
|
||||
"value_bigger_than_max": {
|
||||
"details": "{nodeName} دارای مقداری بیشتر از حداکثر برای {inputName} است.",
|
||||
"detailsWithValue": "مقدار {receivedValue} برای {inputName} در {nodeName} بیشتر از حداکثر {maxValue} است.",
|
||||
@@ -1338,9 +1287,12 @@
|
||||
"missingMedia": "برخی از نودها ورودیهای مورد نیاز را ندارند",
|
||||
"missingModels": "{count} مدل مورد نیاز مفقود است | {count} مدل مورد نیاز مفقود هستند",
|
||||
"missingNodes": "برخی از نودها مفقود هستند و باید نصب شوند",
|
||||
"multipleErrorCount": "{count} خطا یافت شد | {count} خطا یافت شدند",
|
||||
"multipleErrorsMessage": "لطفاً قبل از اجرای workflow، خطاها را برطرف کنید.",
|
||||
"viewDetails": "مشاهده جزئیات"
|
||||
"seeErrors": "مشاهده خطاها",
|
||||
"showMissingMedia": "نمایش ورودیهای مفقود",
|
||||
"showMissingModels": "نمایش مدلهای مفقود",
|
||||
"showMissingNodes": "نمایش نودهای مفقود",
|
||||
"showSwapNodes": "نمایش نودهای قابل جایگزینی",
|
||||
"swapNodes": "برخی از نودها را میتوان با گزینههای جایگزین تعویض کرد"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "ارسال بازخورد",
|
||||
@@ -1424,11 +1376,6 @@
|
||||
"amount": "مقدار",
|
||||
"apply": "اعمال",
|
||||
"architecture": "معماری",
|
||||
"arrange": "چیدمان",
|
||||
"arrangeAsGrid": "چیدمان به صورت شبکهای",
|
||||
"arrangeHorizontally": "چیدمان افقی",
|
||||
"arrangeSpacing": "فاصله چیدمان",
|
||||
"arrangeVertically": "چیدمان عمودی",
|
||||
"asset": "{count} دارایی | {count} دارایی",
|
||||
"audioFailedToLoad": "بارگذاری صوت ناموفق بود",
|
||||
"audioProgress": "پیشرفت صوت",
|
||||
@@ -1494,7 +1441,6 @@
|
||||
"deleteImage": "حذف تصویر",
|
||||
"deprecated": "منسوخ",
|
||||
"description": "توضیحات",
|
||||
"details": "جزئیات",
|
||||
"devOnly": "فقط برای توسعهدهندگان",
|
||||
"devices": "دستگاهها",
|
||||
"disableAll": "غیرفعالسازی همه",
|
||||
@@ -1584,7 +1530,6 @@
|
||||
"jobIdCopied": "شناسه وظیفه در کلیپبورد کپی شد",
|
||||
"keybinding": "کلید میانبر",
|
||||
"keybindingAlreadyExists": "کلید میانبر قبلاً وجود دارد در",
|
||||
"keybindingListAriaLabel": "کلیدهای میانبر: {combos}",
|
||||
"keybindingPresets": {
|
||||
"default": "پیشتنظیم پیشفرض",
|
||||
"deletePreset": "حذف پیشتنظیم",
|
||||
@@ -1635,7 +1580,6 @@
|
||||
"mostRelevant": "مرتبطترین",
|
||||
"multiSelectDropdown": "لیست کشویی چندانتخابی",
|
||||
"nMoreKeybindings": "+ {count} بیشتر",
|
||||
"nMoreKeybindingsCompact": "+ {count}",
|
||||
"name": "نام",
|
||||
"newFolder": "پوشه جدید",
|
||||
"next": "بعدی",
|
||||
@@ -2837,6 +2781,7 @@
|
||||
"replacedNode": "نود جایگزین شد: {nodeType}",
|
||||
"selectAll": "انتخاب همه",
|
||||
"skipForNow": "فعلاً رد شود",
|
||||
"swapNodesGuide": "گرههای زیر میتوانند بهصورت خودکار با گزینههای سازگار جایگزین شوند.",
|
||||
"swapNodesTitle": "جایگزینی گرهها",
|
||||
"unknownNode": "ناشناخته",
|
||||
"willBeReplacedBy": "این گره جایگزین خواهد شد با:"
|
||||
@@ -3008,7 +2953,6 @@
|
||||
"errorHelp": "برای دریافت کمک بیشتر، {github} یا {support}",
|
||||
"errorHelpGithub": "ثبت یک issue در GitHub",
|
||||
"errorHelpSupport": "تماس با پشتیبانی ما",
|
||||
"errorLog": "گزارش خطا",
|
||||
"errors": "خطاها",
|
||||
"executionErrorOccurred": "خطایی در حین اجرا رخ داد. برای جزئیات به تب خطاها مراجعه کنید.",
|
||||
"fallbackGroupTitle": "گروه",
|
||||
@@ -3043,12 +2987,10 @@
|
||||
"hideAdvancedShort": "مخفی کردن پیشرفته",
|
||||
"hideInput": "مخفی کردن ورودی",
|
||||
"info": "اطلاعات",
|
||||
"infoFor": "اطلاعات برای {item}",
|
||||
"inputs": "ورودیها",
|
||||
"inputsNone": "بدون ورودی",
|
||||
"inputsNoneTooltip": "این نود ورودی ندارد",
|
||||
"locateNode": "یافتن node در canvas",
|
||||
"locateNodeFor": "یافتن {item}",
|
||||
"missingMedia": {
|
||||
"audio": "صدا",
|
||||
"cancelSelection": "لغو انتخاب",
|
||||
@@ -3100,6 +3042,7 @@
|
||||
},
|
||||
"missingNodePacks": {
|
||||
"applyChanges": "اعمال تغییرات",
|
||||
"cloudMessage": "این workflow به nodeهای سفارشی نیاز دارد که هنوز در Comfy Cloud موجود نیستند.",
|
||||
"collapse": "جمع کردن",
|
||||
"expand": "باز کردن",
|
||||
"installAll": "نصب همه",
|
||||
@@ -3107,6 +3050,7 @@
|
||||
"installed": "نصب شد",
|
||||
"installing": "در حال نصب...",
|
||||
"ossManagerDisabledHint": "برای نصب nodeهای مورد نیاز، ابتدا دستور {pipCmd} را در محیط پایتون خود اجرا کنید تا Node Manager نصب شود، سپس ComfyUI را با پرچم {flag} مجدداً راهاندازی کنید.",
|
||||
"ossMessage": "این workflow از nodeهای سفارشی استفاده میکند که هنوز نصب نکردهاید.",
|
||||
"searchInManager": "جستجو در Node Manager",
|
||||
"title": "پکهای node مفقود",
|
||||
"unknownPack": "پک ناشناخته",
|
||||
|
||||
@@ -619,31 +619,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BriaVideoGreenScreen": {
|
||||
"description": "جایگزینی پسزمینه ویدیو با یک صفحه کروماکی یکدست با استفاده از Bria.",
|
||||
"display_name": "Bria Video Green Screen",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"green_shade": {
|
||||
"name": "سایه سبز",
|
||||
"tooltip": "سایه کروماکی یکدست که پشت سوژه قرار میگیرد: broadcast_green (#۰۰B۱۴۰)، chroma_green (#۰۰FF۰۰)، یا blue_screen (#۰۰۰۰FF)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "seed تعیین میکند که node دوباره اجرا شود یا خیر؛ نتایج صرفنظر از seed غیرقطعی هستند."
|
||||
},
|
||||
"video": {
|
||||
"name": "ویدیو"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2FirstLastFrameNode": {
|
||||
"description": "تولید ویدیو با استفاده از Seedance 2.0 از تصویر اولین فریم و در صورت نیاز تصویر آخرین فریم.",
|
||||
"display_name": "ByteDance Seedance 2.0 تبدیل اولین-آخرین فریم به ویدیو",
|
||||
@@ -1876,10 +1851,6 @@
|
||||
"0": {
|
||||
"name": "مقدار صحیح RGB",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "هگز",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4026,9 +3997,6 @@
|
||||
"description": "شیء ماسکشده را از تصویر حذف کرده و پسزمینه را بازسازی میکند. ماسک را روی بخشی که میخواهید حذف شود بکشید.",
|
||||
"display_name": "حذف تصویر Flux",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"dilate_pixels": {
|
||||
"name": "dilate_pixels",
|
||||
"tooltip": "مرزهای ماسک را گسترش میدهد تا پوشش تمیز لبههای شیء تضمین شود."
|
||||
@@ -4039,10 +4007,6 @@
|
||||
"mask": {
|
||||
"name": "mask",
|
||||
"tooltip": "ناحیههای سفید حذف میشوند؛ ناحیههای سیاه حفظ میشوند."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "seed تصادفی مورد استفاده برای ایجاد نویز."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -4786,14 +4750,6 @@
|
||||
"system_prompt": {
|
||||
"name": "پرامپت سیستمی",
|
||||
"tooltip": "دستورالعملهای پایهای که رفتار هوش مصنوعی را تعیین میکند."
|
||||
},
|
||||
"temperature": {
|
||||
"name": "temperature",
|
||||
"tooltip": "میزان تصادفی بودن در تولید را کنترل میکند. مقدار کمتر، خروجی متمرکزتر و قطعیتر است."
|
||||
},
|
||||
"top_p": {
|
||||
"name": "top_p",
|
||||
"tooltip": "آستانه نمونهگیری nucleus. مقدار کمتر متمرکزتر، مقدار بیشتر متنوعتر."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -4855,48 +4811,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNodeV2": {
|
||||
"description": "تولید پاسخ متنی با مدلهای Gemini گوگل. یک متن راهنما و در صورت نیاز یک یا چند تصویر، کلیپ صوتی، ویدیو یا فایل به عنوان زمینه چندرسانهای ارائه دهید.",
|
||||
"display_name": "Google Gemini",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "کنترل پس از تولید"
|
||||
},
|
||||
"model": {
|
||||
"name": "مدل",
|
||||
"tooltip": "مدل Gemini مورد استفاده برای تولید پاسخ."
|
||||
},
|
||||
"model_max_output_tokens": {
|
||||
"name": "حداکثر توکن خروجی"
|
||||
},
|
||||
"model_temperature": {
|
||||
"name": "temperature"
|
||||
},
|
||||
"model_thinking_level": {
|
||||
"name": "thinking_level"
|
||||
},
|
||||
"model_top_p": {
|
||||
"name": "top_p"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "راهنما",
|
||||
"tooltip": "متن ورودی برای مدل. شامل دستورالعملهای دقیق، پرسشها یا زمینه باشد."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "seed برای نمونهگیری. مقدار ۰ برای seed تصادفی. خروجی قطعی تضمین نمیشود."
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "system_prompt",
|
||||
"tooltip": "دستورالعملهای پایه که رفتار مدل را تعیین میکند."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GenerateTracks": {
|
||||
"display_name": "تولید مسیرها",
|
||||
"inputs": {
|
||||
@@ -13811,15 +13725,15 @@
|
||||
"height": {
|
||||
"name": "ارتفاع"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "مدل ۳بعدی",
|
||||
"tooltip": "فایل مدل ۳بعدی از node سهبعدی بالادستی."
|
||||
"image": {
|
||||
"name": "تصویر"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "اطلاعات مدل سهبعدی"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "وضعیت نمای دید"
|
||||
"model_file": {
|
||||
"name": "فایل مدل",
|
||||
"tooltip": "فایل مدل سهبعدی از یک node سهبعدی بالادستی."
|
||||
},
|
||||
"width": {
|
||||
"name": "عرض"
|
||||
@@ -13874,52 +13788,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewGaussianSplat": {
|
||||
"display_name": "پیشنمایش Splat",
|
||||
"inputs": {
|
||||
"camera_info": {
|
||||
"name": "camera_info"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "یک فایل سهبعدی gaussian splat."
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "model_3d_info"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "viewport_state"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "model_3d_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "camera_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "width",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "height",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewImage": {
|
||||
"description": "تصاویر ورودی را در پوشه خروجی ComfyUI شما ذخیره میکند.",
|
||||
"display_name": "پیشنمایش تصویر",
|
||||
@@ -13929,52 +13797,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewPointCloud": {
|
||||
"display_name": "پیشنمایش Point Cloud",
|
||||
"inputs": {
|
||||
"camera_info": {
|
||||
"name": "camera_info"
|
||||
},
|
||||
"height": {
|
||||
"name": "height"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "model_3d",
|
||||
"tooltip": "فایل point cloud (.ply)"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "model_3d_info"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "viewport_state"
|
||||
},
|
||||
"width": {
|
||||
"name": "width"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "model_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "model_3d_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "camera_info",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "width",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "height",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PrimitiveBoolean": {
|
||||
"display_name": "بولین",
|
||||
"inputs": {
|
||||
@@ -15222,10 +15044,6 @@
|
||||
"megapixels": {
|
||||
"name": "مگاپیکسل",
|
||||
"tooltip": "هدف مجموع مگاپیکسلها. ۱٫۰ مگاپیکسل ≈ ۱۰۲۴×۱۰۲۴ برای تصویر مربع."
|
||||
},
|
||||
"multiple": {
|
||||
"name": "ضربکننده",
|
||||
"tooltip": "نزدیکترین مضرب نتیجه برای تنظیم وضوح انتخابشده."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -16631,27 +16449,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioAdvanced": {
|
||||
"description": "صدای ورودی را در پوشه خروجی ComfyUI شما ذخیره میکند.",
|
||||
"display_name": "ذخیره صدا (پیشرفته)",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "صدایی که باید ذخیره شود."
|
||||
},
|
||||
"audioUI": {
|
||||
"name": "audioUI"
|
||||
},
|
||||
"filename_prefix": {
|
||||
"name": "filename_prefix",
|
||||
"tooltip": "پیشوند فایل برای ذخیره. میتواند شامل توکنهای قالببندی مانند %date:yyyy-MM-dd% باشد."
|
||||
},
|
||||
"format": {
|
||||
"name": "format",
|
||||
"tooltip": "فرمت فایلی که صدا در آن ذخیره میشود."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioMP3": {
|
||||
"display_name": "ذخیره صدا (MP3)",
|
||||
"inputs": {
|
||||
|
||||
@@ -832,9 +832,7 @@
|
||||
"FILE_3D_KSPLAT": "FILE_3D_KSPLAT",
|
||||
"FILE_3D_OBJ": "FICHIER_3D_OBJ",
|
||||
"FILE_3D_PLY": "FILE_3D_PLY",
|
||||
"FILE_3D_POINT_CLOUD_ANY": "FILE_3D_POINT_CLOUD_ANY",
|
||||
"FILE_3D_SPLAT": "FILE_3D_SPLAT",
|
||||
"FILE_3D_SPLAT_ANY": "FILE_3D_SPLAT_ANY",
|
||||
"FILE_3D_SPZ": "FILE_3D_SPZ",
|
||||
"FILE_3D_STL": "FICHIER_3D_STL",
|
||||
"FILE_3D_USDZ": "FICHIER_3D_USDZ",
|
||||
@@ -948,46 +946,6 @@
|
||||
"inputName": "entrée inconnue",
|
||||
"nodeName": "Ce nœud"
|
||||
},
|
||||
"missingErrors": {
|
||||
"missing_media": {
|
||||
"displayMessage": "Une entrée média requise n’a pas de fichier sélectionné.",
|
||||
"toastMessageMany": "Veuillez sélectionner les entrées média manquantes avant d’exécuter ce workflow.",
|
||||
"toastMessageWithNode": "{nodeName} nécessite un fichier média requis.",
|
||||
"toastTitleMany": "Entrées média manquantes",
|
||||
"toastTitleOne": "Entrée média manquante"
|
||||
},
|
||||
"missing_model": {
|
||||
"displayMessageCloud": "Importez un modèle ou ouvrez le nœud pour le remplacer.",
|
||||
"displayMessageOss": "Téléchargez un modèle ou ouvrez le nœud pour le remplacer.",
|
||||
"toastMessageManyCloud": "Certains modèles ne sont pas pris en charge. Veuillez en choisir d’autres.",
|
||||
"toastMessageManyOss": "{count} fichiers de modèles sont manquants.",
|
||||
"toastMessageOneCloud": "Ce modèle n’est pas pris en charge. Choisissez-en un autre.",
|
||||
"toastMessageOneOss": "{nodeName} nécessite un fichier modèle manquant.",
|
||||
"toastTitleMany": "Modèles manquants",
|
||||
"toastTitleManyCloud": "Des modèles ne sont pas disponibles sur le Cloud",
|
||||
"toastTitleOneCloud": "{modelName} n’est pas disponible sur le Cloud",
|
||||
"toastTitleOneOss": "{modelName} est manquant"
|
||||
},
|
||||
"missing_node": {
|
||||
"displayMessageCloud": "Les nœuds personnalisés requis ne sont pas pris en charge sur le Cloud. Remplacez-les par des nœuds compatibles.",
|
||||
"displayMessageOss": "Installez les packs manquants pour utiliser ce workflow.",
|
||||
"toastMessageManyCloud": "Ce workflow utilise des nœuds qui ne sont pas pris en charge sur le Cloud.",
|
||||
"toastMessageManyOss": "{count} nœuds nécessitent des packs de nœuds manquants.",
|
||||
"toastMessageOneCloud": "Ce nœud n’est pas pris en charge sur le Cloud.",
|
||||
"toastMessageOneOss": "Ce workflow utilise un nœud personnalisé qui n’est pas installé. Installez-le depuis le registre ou remplacez le nœud.",
|
||||
"toastTitleManyCloud": "Des nœuds ne sont pas disponibles sur le Cloud",
|
||||
"toastTitleManyOss": "Nœuds manquants",
|
||||
"toastTitleOneCloud": "{nodeType} n’est pas disponible sur le Cloud",
|
||||
"toastTitleOneOss": "Nœud manquant : {nodeType}"
|
||||
},
|
||||
"swap_nodes": {
|
||||
"displayMessage": "Certains nœuds peuvent être remplacés par des alternatives",
|
||||
"toastMessageMany": "{count} types de nœuds peuvent être remplacés par des alternatives compatibles.",
|
||||
"toastMessageOne": "Remplacez-le par {replacementNodeType} depuis le panneau d’erreur.",
|
||||
"toastTitleMany": "Des nœuds peuvent être remplacés",
|
||||
"toastTitleOne": "{nodeType} peut être remplacé"
|
||||
}
|
||||
},
|
||||
"promptErrors": {
|
||||
"missing_node_type": {
|
||||
"desc": "Un type de nœud est manquant ou indisponible. Le workflow peut être corrompu ou nécessiter un nœud personnalisé.",
|
||||
@@ -1282,15 +1240,6 @@
|
||||
"toastMessageWithTypes": "L'entrée {inputName} de {nodeName} attend {expectedType}, mais la sortie connectée est {receivedType}.",
|
||||
"toastTitle": "Connexion invalide"
|
||||
},
|
||||
"unknown_validation_error": {
|
||||
"details": "{nodeName} a retourné une erreur de validation non reconnue : {errorType}",
|
||||
"detailsWithRawDetails": "{nodeName} a retourné une erreur de validation non reconnue ({errorType}) : {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"message": "Un nœud a retourné une erreur de validation non reconnue par ComfyUI.",
|
||||
"title": "Échec de la validation",
|
||||
"toastMessage": "{nodeName} a retourné une erreur de validation non reconnue.",
|
||||
"toastTitle": "Échec de la validation"
|
||||
},
|
||||
"value_bigger_than_max": {
|
||||
"details": "{nodeName} a une valeur supérieure au maximum pour {inputName}.",
|
||||
"detailsWithValue": "La valeur {receivedValue} pour l'entrée {inputName} de {nodeName} est supérieure au maximum {maxValue}.",
|
||||
@@ -1338,9 +1287,12 @@
|
||||
"missingMedia": "Certains nœuds n'ont pas les entrées requises",
|
||||
"missingModels": "{count} modèle requis est manquant | {count} modèles requis sont manquants",
|
||||
"missingNodes": "Certains nœuds sont manquants et doivent être installés",
|
||||
"multipleErrorCount": "{count} erreur trouvée | {count} erreurs trouvées",
|
||||
"multipleErrorsMessage": "Veuillez les résoudre avant d’exécuter le workflow.",
|
||||
"viewDetails": "Voir les détails"
|
||||
"seeErrors": "Voir les erreurs",
|
||||
"showMissingMedia": "Afficher les entrées manquantes",
|
||||
"showMissingModels": "Afficher les modèles manquants",
|
||||
"showMissingNodes": "Afficher les nœuds manquants",
|
||||
"showSwapNodes": "Afficher les nœuds de remplacement",
|
||||
"swapNodes": "Certains nœuds peuvent être remplacés par des alternatives"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "Donner votre avis",
|
||||
@@ -1424,11 +1376,6 @@
|
||||
"amount": "Quantité",
|
||||
"apply": "Appliquer",
|
||||
"architecture": "Architecture",
|
||||
"arrange": "Organiser",
|
||||
"arrangeAsGrid": "Organiser en grille",
|
||||
"arrangeHorizontally": "Organiser horizontalement",
|
||||
"arrangeSpacing": "Espacement de l’organisation",
|
||||
"arrangeVertically": "Organiser verticalement",
|
||||
"asset": "{count} ressource | {count} ressources",
|
||||
"audioFailedToLoad": "Échec du chargement de l'audio",
|
||||
"audioProgress": "Progression audio",
|
||||
@@ -1494,7 +1441,6 @@
|
||||
"deleteImage": "Supprimer l'image",
|
||||
"deprecated": "DEPR",
|
||||
"description": "Description",
|
||||
"details": "Détails",
|
||||
"devOnly": "DEV",
|
||||
"devices": "Appareils",
|
||||
"disableAll": "Désactiver tout",
|
||||
@@ -1584,7 +1530,6 @@
|
||||
"jobIdCopied": "ID du travail copié dans le presse-papiers",
|
||||
"keybinding": "Raccourci clavier",
|
||||
"keybindingAlreadyExists": "Le raccourci clavier existe déjà",
|
||||
"keybindingListAriaLabel": "Raccourcis clavier : {combos}",
|
||||
"keybindingPresets": {
|
||||
"default": "Préréglage par défaut",
|
||||
"deletePreset": "Supprimer le préréglage",
|
||||
@@ -1635,7 +1580,6 @@
|
||||
"mostRelevant": "Les plus pertinents",
|
||||
"multiSelectDropdown": "Menu déroulant à sélection multiple",
|
||||
"nMoreKeybindings": "+ {count} de plus",
|
||||
"nMoreKeybindingsCompact": "+ {count}",
|
||||
"name": "Nom",
|
||||
"newFolder": "Nouveau dossier",
|
||||
"next": "Suivant",
|
||||
@@ -2837,6 +2781,7 @@
|
||||
"replacedNode": "Nœud remplacé : {nodeType}",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"skipForNow": "Ignorer pour l’instant",
|
||||
"swapNodesGuide": "Les nœuds suivants peuvent être automatiquement remplacés par des alternatives compatibles.",
|
||||
"swapNodesTitle": "Échanger les nœuds",
|
||||
"unknownNode": "Inconnu",
|
||||
"willBeReplacedBy": "Ce nœud sera remplacé par :"
|
||||
@@ -3008,7 +2953,6 @@
|
||||
"errorHelp": "Pour plus d'aide, {github} ou {support}",
|
||||
"errorHelpGithub": "soumettre un ticket GitHub",
|
||||
"errorHelpSupport": "contacter notre support",
|
||||
"errorLog": "Journal des erreurs",
|
||||
"errors": "Erreurs",
|
||||
"executionErrorOccurred": "Une erreur est survenue lors de l’exécution. Consultez l’onglet Erreurs pour plus de détails.",
|
||||
"fallbackGroupTitle": "Groupe",
|
||||
@@ -3043,12 +2987,10 @@
|
||||
"hideAdvancedShort": "Masquer les options avancées",
|
||||
"hideInput": "Masquer l’entrée",
|
||||
"info": "Infos",
|
||||
"infoFor": "Infos pour {item}",
|
||||
"inputs": "ENTRÉES",
|
||||
"inputsNone": "AUCUNE ENTRÉE",
|
||||
"inputsNoneTooltip": "Le nœud n’a pas d’entrées",
|
||||
"locateNode": "Localiser le nœud sur le canevas",
|
||||
"locateNodeFor": "Localiser {item}",
|
||||
"missingMedia": {
|
||||
"audio": "Audio",
|
||||
"cancelSelection": "Annuler la sélection",
|
||||
@@ -3100,6 +3042,7 @@
|
||||
},
|
||||
"missingNodePacks": {
|
||||
"applyChanges": "Appliquer les modifications",
|
||||
"cloudMessage": "Ce workflow nécessite des nœuds personnalisés qui ne sont pas encore disponibles sur Comfy Cloud.",
|
||||
"collapse": "Réduire",
|
||||
"expand": "Développer",
|
||||
"installAll": "Tout installer",
|
||||
@@ -3107,6 +3050,7 @@
|
||||
"installed": "Installé",
|
||||
"installing": "Installation en cours...",
|
||||
"ossManagerDisabledHint": "Pour installer les nodes manquants, exécutez d'abord {pipCmd} dans votre environnement Python pour installer le Node Manager, puis redémarrez ComfyUI avec le paramètre {flag}.",
|
||||
"ossMessage": "Ce workflow utilise des nœuds personnalisés que vous n'avez pas encore installés.",
|
||||
"searchInManager": "Rechercher dans le gestionnaire de nœuds",
|
||||
"title": "Packs de nœuds manquants",
|
||||
"unknownPack": "Pack inconnu",
|
||||
|
||||
@@ -619,31 +619,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"BriaVideoGreenScreen": {
|
||||
"description": "Remplacez l’arrière-plan d’une vidéo par un écran uni de chroma-key à l’aide de Bria.",
|
||||
"display_name": "Bria Video Green Screen",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"green_shade": {
|
||||
"name": "teinte verte",
|
||||
"tooltip": "Teinte unie de chroma-key appliquée derrière le premier plan : broadcast_green (#00B140), chroma_green (#00FF00) ou blue_screen (#0000FF)."
|
||||
},
|
||||
"seed": {
|
||||
"name": "graine",
|
||||
"tooltip": "La graine contrôle si le nœud doit être relancé ; les résultats restent non déterministes quel que soit la graine."
|
||||
},
|
||||
"video": {
|
||||
"name": "vidéo"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"ByteDance2FirstLastFrameNode": {
|
||||
"description": "Générez une vidéo avec Seedance 2.0 à partir d'une image de première image et, optionnellement, d'une image de dernière image.",
|
||||
"display_name": "ByteDance Seedance 2.0 Première-Dernière-Image vers Vidéo",
|
||||
@@ -1876,10 +1851,6 @@
|
||||
"0": {
|
||||
"name": "rgb_int",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "hex",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4026,9 +3997,6 @@
|
||||
"description": "Supprime l'objet masqué d'une image et reconstruit l'arrière-plan. Peignez le mask sur ce que vous souhaitez effacer.",
|
||||
"display_name": "Flux Effacer l'image",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"dilate_pixels": {
|
||||
"name": "dilate_pixels",
|
||||
"tooltip": "Étend les bords du mask pour garantir une couverture propre des contours de l'objet."
|
||||
@@ -4039,10 +4007,6 @@
|
||||
"mask": {
|
||||
"name": "mask",
|
||||
"tooltip": "Les zones blanches sont supprimées ; les zones noires sont conservées."
|
||||
},
|
||||
"seed": {
|
||||
"name": "graine",
|
||||
"tooltip": "La graine aléatoire utilisée pour créer le bruit."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -4786,14 +4750,6 @@
|
||||
"system_prompt": {
|
||||
"name": "invite système",
|
||||
"tooltip": "Instructions fondamentales qui dictent le comportement de l'IA."
|
||||
},
|
||||
"temperature": {
|
||||
"name": "température",
|
||||
"tooltip": "Contrôle l’aléa dans la génération. Plus bas = plus ciblé/déterministe."
|
||||
},
|
||||
"top_p": {
|
||||
"name": "top_p",
|
||||
"tooltip": "Seuil d’échantillonnage nucleus. Plus bas = plus ciblé, plus haut = plus diversifié."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -4855,48 +4811,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"GeminiNodeV2": {
|
||||
"description": "Générez des réponses textuelles avec les modèles Gemini de Google. Fournissez une invite textuelle et, en option, une ou plusieurs images, clips audio, vidéos ou fichiers comme contexte multimodal.",
|
||||
"display_name": "Google Gemini",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "contrôle après génération"
|
||||
},
|
||||
"model": {
|
||||
"name": "modèle",
|
||||
"tooltip": "Le modèle Gemini utilisé pour générer la réponse."
|
||||
},
|
||||
"model_max_output_tokens": {
|
||||
"name": "max_output_tokens"
|
||||
},
|
||||
"model_temperature": {
|
||||
"name": "température"
|
||||
},
|
||||
"model_thinking_level": {
|
||||
"name": "niveau de réflexion"
|
||||
},
|
||||
"model_top_p": {
|
||||
"name": "top_p"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "invite",
|
||||
"tooltip": "Texte d’entrée pour le modèle. Incluez des instructions détaillées, des questions ou du contexte."
|
||||
},
|
||||
"seed": {
|
||||
"name": "graine",
|
||||
"tooltip": "Graine pour l’échantillonnage. Mettre à 0 pour une graine aléatoire. Le résultat déterministe n’est pas garanti."
|
||||
},
|
||||
"system_prompt": {
|
||||
"name": "invite système",
|
||||
"tooltip": "Instructions fondamentales qui dictent le comportement du modèle."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"GenerateTracks": {
|
||||
"display_name": "GenerateTracks",
|
||||
"inputs": {
|
||||
@@ -13811,15 +13725,15 @@
|
||||
"height": {
|
||||
"name": "hauteur"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "modèle 3D",
|
||||
"tooltip": "Fichier de modèle 3D provenant d’un nœud 3D en amont."
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "infos_modèle_3d"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "état de la vue"
|
||||
"model_file": {
|
||||
"name": "fichier_modèle",
|
||||
"tooltip": "Fichier de modèle 3D provenant d'un nœud 3D en amont."
|
||||
},
|
||||
"width": {
|
||||
"name": "largeur"
|
||||
@@ -13874,52 +13788,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewGaussianSplat": {
|
||||
"display_name": "Aperçu Splat",
|
||||
"inputs": {
|
||||
"camera_info": {
|
||||
"name": "info_caméra"
|
||||
},
|
||||
"height": {
|
||||
"name": "hauteur"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "modèle_3d",
|
||||
"tooltip": "Un fichier 3D gaussian splat."
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "info_modèle_3d"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "état_vue"
|
||||
},
|
||||
"width": {
|
||||
"name": "largeur"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "modèle_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "info_modèle_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "info_caméra",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "largeur",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "hauteur",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewImage": {
|
||||
"description": "Enregistre les images d'entrée dans votre répertoire de sortie ComfyUI.",
|
||||
"display_name": "Aperçu Image",
|
||||
@@ -13929,52 +13797,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"PreviewPointCloud": {
|
||||
"display_name": "Aperçu Nuage de Points",
|
||||
"inputs": {
|
||||
"camera_info": {
|
||||
"name": "info_caméra"
|
||||
},
|
||||
"height": {
|
||||
"name": "hauteur"
|
||||
},
|
||||
"model_3d": {
|
||||
"name": "modèle_3d",
|
||||
"tooltip": "Fichier nuage de points (.ply)"
|
||||
},
|
||||
"model_3d_info": {
|
||||
"name": "info_modèle_3d"
|
||||
},
|
||||
"viewport_state": {
|
||||
"name": "état_vue"
|
||||
},
|
||||
"width": {
|
||||
"name": "largeur"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "modèle_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"1": {
|
||||
"name": "info_modèle_3d",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "info_caméra",
|
||||
"tooltip": null
|
||||
},
|
||||
"3": {
|
||||
"name": "largeur",
|
||||
"tooltip": null
|
||||
},
|
||||
"4": {
|
||||
"name": "hauteur",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"PrimitiveBoolean": {
|
||||
"display_name": "Booléen",
|
||||
"inputs": {
|
||||
@@ -15222,10 +15044,6 @@
|
||||
"megapixels": {
|
||||
"name": "mégapixels",
|
||||
"tooltip": "Nombre total de mégapixels visé. 1,0 MP ≈ 1024×1024 pour un carré."
|
||||
},
|
||||
"multiple": {
|
||||
"name": "multiple",
|
||||
"tooltip": "Multiplicateur le plus proche du résultat pour définir la résolution sélectionnée."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -16631,27 +16449,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioAdvanced": {
|
||||
"description": "Enregistre l’audio d’entrée dans le dossier de sortie ComfyUI.",
|
||||
"display_name": "Enregistrer l’Audio (Avancé)",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "audio",
|
||||
"tooltip": "L’audio à enregistrer."
|
||||
},
|
||||
"audioUI": {
|
||||
"name": "audioUI"
|
||||
},
|
||||
"filename_prefix": {
|
||||
"name": "préfixe_nom_fichier",
|
||||
"tooltip": "Le préfixe du fichier à enregistrer. Peut inclure des jetons de formatage comme %date:yyyy-MM-dd%."
|
||||
},
|
||||
"format": {
|
||||
"name": "format",
|
||||
"tooltip": "Le format de fichier dans lequel enregistrer l’audio."
|
||||
}
|
||||
}
|
||||
},
|
||||
"SaveAudioMP3": {
|
||||
"display_name": "Enregistrer Audio (MP3)",
|
||||
"inputs": {
|
||||
|
||||
@@ -832,9 +832,7 @@
|
||||
"FILE_3D_KSPLAT": "FILE_3D_KSPLAT",
|
||||
"FILE_3D_OBJ": "FILE_3D_OBJ",
|
||||
"FILE_3D_PLY": "FILE_3D_PLY",
|
||||
"FILE_3D_POINT_CLOUD_ANY": "FILE_3D_POINT_CLOUD_ANY",
|
||||
"FILE_3D_SPLAT": "FILE_3D_SPLAT",
|
||||
"FILE_3D_SPLAT_ANY": "FILE_3D_SPLAT_ANY",
|
||||
"FILE_3D_SPZ": "FILE_3D_SPZ",
|
||||
"FILE_3D_STL": "FILE_3D_STL",
|
||||
"FILE_3D_USDZ": "FILE_3D_USDZ",
|
||||
@@ -948,46 +946,6 @@
|
||||
"inputName": "不明な入力",
|
||||
"nodeName": "このノード"
|
||||
},
|
||||
"missingErrors": {
|
||||
"missing_media": {
|
||||
"displayMessage": "必要なメディア入力にファイルが選択されていません。",
|
||||
"toastMessageMany": "このワークフローを実行する前に不足しているメディア入力を選択してください。",
|
||||
"toastMessageWithNode": "{nodeName}に必要なメディアファイルが不足しています。",
|
||||
"toastTitleMany": "メディア入力が不足しています",
|
||||
"toastTitleOne": "メディア入力が不足しています"
|
||||
},
|
||||
"missing_model": {
|
||||
"displayMessageCloud": "モデルをインポートするか、ノードを開いて置き換えてください。",
|
||||
"displayMessageOss": "モデルをダウンロードするか、ノードを開いて置き換えてください。",
|
||||
"toastMessageManyCloud": "一部のモデルはサポートされていません。別のモデルを選択してください。",
|
||||
"toastMessageManyOss": "{count}個のモデルファイルが不足しています。",
|
||||
"toastMessageOneCloud": "このモデルはサポートされていません。別のモデルを選択してください。",
|
||||
"toastMessageOneOss": "{nodeName}に必要なモデルファイルが不足しています。",
|
||||
"toastTitleMany": "モデルが不足しています",
|
||||
"toastTitleManyCloud": "モデルはCloudで利用できません",
|
||||
"toastTitleOneCloud": "{modelName}はCloudで利用できません",
|
||||
"toastTitleOneOss": "{modelName}が不足しています"
|
||||
},
|
||||
"missing_node": {
|
||||
"displayMessageCloud": "必要なカスタムノードはCloudではサポートされていません。サポートされているノードに置き換えてください。",
|
||||
"displayMessageOss": "このワークフローを使用するには不足しているパックをインストールしてください。",
|
||||
"toastMessageManyCloud": "このワークフローはCloudでサポートされていないノードを使用しています。",
|
||||
"toastMessageManyOss": "{count}個のノードパックが不足しています。",
|
||||
"toastMessageOneCloud": "このノードはCloudでサポートされていません。",
|
||||
"toastMessageOneOss": "このワークフローはインストールされていないカスタムノードを使用しています。レジストリからインストールするか、ノードを置き換えてください。",
|
||||
"toastTitleManyCloud": "ノードはCloudで利用できません",
|
||||
"toastTitleManyOss": "ノードが不足しています",
|
||||
"toastTitleOneCloud": "{nodeType}はCloudで利用できません",
|
||||
"toastTitleOneOss": "ノードが不足しています: {nodeType}"
|
||||
},
|
||||
"swap_nodes": {
|
||||
"displayMessage": "一部のノードは代替ノードに置き換えることができます",
|
||||
"toastMessageMany": "{count}種類のノードは互換性のある代替ノードに置き換え可能です。",
|
||||
"toastMessageOne": "エラーパネルから{replacementNodeType}に置き換えてください。",
|
||||
"toastTitleMany": "ノードは置き換え可能です",
|
||||
"toastTitleOne": "{nodeType}は置き換え可能です"
|
||||
}
|
||||
},
|
||||
"promptErrors": {
|
||||
"missing_node_type": {
|
||||
"desc": "ノードタイプが存在しないか利用できません。ワークフローが破損しているか、カスタムノードが必要な場合があります。",
|
||||
@@ -1282,15 +1240,6 @@
|
||||
"toastMessageWithTypes": "{nodeName} の {inputName} 入力は {expectedType} を期待していますが、接続された出力は {receivedType} です。",
|
||||
"toastTitle": "無効な接続"
|
||||
},
|
||||
"unknown_validation_error": {
|
||||
"details": "{nodeName} が未認識の検証エラーを返しました: {errorType}",
|
||||
"detailsWithRawDetails": "{nodeName} が未認識の検証エラー ({errorType}) を返しました: {rawDetails}",
|
||||
"itemLabel": "{nodeName}",
|
||||
"message": "ノードが ComfyUI で認識されない検証エラーを返しました。",
|
||||
"title": "検証に失敗しました",
|
||||
"toastMessage": "{nodeName} が未認識の検証エラーを返しました。",
|
||||
"toastTitle": "検証に失敗しました"
|
||||
},
|
||||
"value_bigger_than_max": {
|
||||
"details": "{nodeName} の {inputName} が最大値を超えています。",
|
||||
"detailsWithValue": "{nodeName} の {inputName} に対する値 {receivedValue} は最大値 {maxValue} を超えています。",
|
||||
@@ -1338,9 +1287,12 @@
|
||||
"missingMedia": "いくつかのノードに必要な入力が不足しています",
|
||||
"missingModels": "{count} 個の必要なモデルが不足しています",
|
||||
"missingNodes": "いくつかのノードが不足しており、インストールが必要です",
|
||||
"multipleErrorCount": "{count}件のエラーが見つかりました",
|
||||
"multipleErrorsMessage": "ワークフローを実行する前に解決してください。",
|
||||
"viewDetails": "詳細を表示"
|
||||
"seeErrors": "エラーを表示",
|
||||
"showMissingMedia": "不足している入力を表示",
|
||||
"showMissingModels": "不足しているモデルを表示",
|
||||
"showMissingNodes": "不足しているノードを表示",
|
||||
"showSwapNodes": "代替可能なノードを表示",
|
||||
"swapNodes": "いくつかのノードは代替可能です"
|
||||
},
|
||||
"errorPanelSurvey": {
|
||||
"ctaButton": "フィードバックを送る",
|
||||
@@ -1424,11 +1376,6 @@
|
||||
"amount": "量",
|
||||
"apply": "適用する",
|
||||
"architecture": "アーキテクチャ",
|
||||
"arrange": "配置",
|
||||
"arrangeAsGrid": "グリッドとして配置",
|
||||
"arrangeHorizontally": "横に配置",
|
||||
"arrangeSpacing": "配置間隔",
|
||||
"arrangeVertically": "縦に配置",
|
||||
"asset": "{count} 個のアセット | {count} 個のアセット",
|
||||
"audioFailedToLoad": "オーディオの読み込みに失敗しました",
|
||||
"audioProgress": "オーディオの進捗",
|
||||
@@ -1494,7 +1441,6 @@
|
||||
"deleteImage": "画像を削除",
|
||||
"deprecated": "非推奨",
|
||||
"description": "説明",
|
||||
"details": "詳細",
|
||||
"devOnly": "DEV",
|
||||
"devices": "デバイス",
|
||||
"disableAll": "すべて無効にする",
|
||||
@@ -1584,7 +1530,6 @@
|
||||
"jobIdCopied": "ジョブIDがクリップボードにコピーされました",
|
||||
"keybinding": "キーバインディング",
|
||||
"keybindingAlreadyExists": "このキー割り当てはすでに存在します",
|
||||
"keybindingListAriaLabel": "キーバインド: {combos}",
|
||||
"keybindingPresets": {
|
||||
"default": "デフォルトプリセット",
|
||||
"deletePreset": "プリセットを削除",
|
||||
@@ -1635,7 +1580,6 @@
|
||||
"mostRelevant": "最も関連性の高い",
|
||||
"multiSelectDropdown": "複数選択ドロップダウン",
|
||||
"nMoreKeybindings": "+ {count} 件以上",
|
||||
"nMoreKeybindingsCompact": "+ {count}",
|
||||
"name": "名前",
|
||||
"newFolder": "新しいフォルダー",
|
||||
"next": "次へ",
|
||||
@@ -2837,6 +2781,7 @@
|
||||
"replacedNode": "置き換えたノード: {nodeType}",
|
||||
"selectAll": "すべて選択",
|
||||
"skipForNow": "今はスキップ",
|
||||
"swapNodesGuide": "以下のノードは互換性のある代替品に自動的に置き換えることができます。",
|
||||
"swapNodesTitle": "ノードの入れ替え",
|
||||
"unknownNode": "不明",
|
||||
"willBeReplacedBy": "このノードは次で置き換えられます:"
|
||||
@@ -3008,7 +2953,6 @@
|
||||
"errorHelp": "詳細なヘルプについては、{github} または {support} をご利用ください",
|
||||
"errorHelpGithub": "GitHub イシューを提出",
|
||||
"errorHelpSupport": "サポートに連絡",
|
||||
"errorLog": "エラーログ",
|
||||
"errors": "エラー",
|
||||
"executionErrorOccurred": "実行中にエラーが発生しました。詳細はエラータブをご確認ください。",
|
||||
"fallbackGroupTitle": "グループ",
|
||||
@@ -3043,12 +2987,10 @@
|
||||
"hideAdvancedShort": "詳細を非表示",
|
||||
"hideInput": "入力を非表示",
|
||||
"info": "情報",
|
||||
"infoFor": "{item} の情報",
|
||||
"inputs": "入力",
|
||||
"inputsNone": "入力なし",
|
||||
"inputsNoneTooltip": "このノードには入力がありません",
|
||||
"locateNode": "キャンバス上でノードを探す",
|
||||
"locateNodeFor": "{item} を探す",
|
||||
"missingMedia": {
|
||||
"audio": "音声",
|
||||
"cancelSelection": "選択をキャンセル",
|
||||
@@ -3100,6 +3042,7 @@
|
||||
},
|
||||
"missingNodePacks": {
|
||||
"applyChanges": "変更を適用",
|
||||
"cloudMessage": "このワークフローにはComfy Cloudでまだ利用できないカスタムノードが必要です。",
|
||||
"collapse": "折りたたむ",
|
||||
"expand": "展開",
|
||||
"installAll": "すべてインストール",
|
||||
@@ -3107,6 +3050,7 @@
|
||||
"installed": "インストール済み",
|
||||
"installing": "インストール中...",
|
||||
"ossManagerDisabledHint": "不足しているノードをインストールするには、まずPython環境で {pipCmd} を実行してNode Managerをインストールし、{flag} フラグを付けてComfyUIを再起動してください。",
|
||||
"ossMessage": "このワークフローにはまだインストールしていないカスタムノードが使われています。",
|
||||
"searchInManager": "ノードマネージャーで検索",
|
||||
"title": "不足しているノードパック",
|
||||
"unknownPack": "不明なパック",
|
||||
|
||||