mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
Compare commits
36 Commits
glary/down
...
FE-446-loa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
633ec60dd8 | ||
|
|
21406dceb1 | ||
|
|
14320a131f | ||
|
|
a763c7132c | ||
|
|
3f223dbbb4 | ||
|
|
60f789d580 | ||
|
|
24fc11aa3e | ||
|
|
055486cac0 | ||
|
|
f6ddd26cef | ||
|
|
6822a6883d | ||
|
|
3637b61fcd | ||
|
|
d1df5fadf8 | ||
|
|
7d67fe364b | ||
|
|
7c2321cc23 | ||
|
|
a877ccde94 | ||
|
|
e3883f4a2c | ||
|
|
5e16802832 | ||
|
|
0e9a5ecbe9 | ||
|
|
9013102db9 | ||
|
|
6ea5a5e32d | ||
|
|
90b3d8a5c6 | ||
|
|
551cf21fb1 | ||
|
|
2c8ecd82ec | ||
|
|
7b59c561ff | ||
|
|
8b1d564729 | ||
|
|
ea2e8e59f2 | ||
|
|
1f60f7cfcc | ||
|
|
5e3266e0c2 | ||
|
|
b5b502755f | ||
|
|
5fbcea6b27 | ||
|
|
ac36dc47a4 | ||
|
|
aef71852f0 | ||
|
|
94b570a177 | ||
|
|
846412af17 | ||
|
|
aa2169e108 | ||
|
|
cc24d1411a |
@@ -114,7 +114,7 @@ await expect(async () => {
|
||||
## CI Debugging
|
||||
|
||||
1. Download artifacts from failed CI run
|
||||
2. Extract and view trace: `npx playwright show-trace trace.zip`
|
||||
2. Extract and view trace: `pnpm dlx playwright show-trace trace.zip`
|
||||
3. CI deploys HTML report to Cloudflare Pages (link in PR comment)
|
||||
4. Reproduce CI: `CI=true pnpm test:browser`
|
||||
5. Local runs: `pnpm test:browser:local`
|
||||
|
||||
44
apps/website/e2e/demos.spec.ts
Normal file
44
apps/website/e2e/demos.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test.describe('Demo pages @smoke', () => {
|
||||
test('demo detail page renders hero and embed', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'Create a Video from an Image'
|
||||
)
|
||||
const iframe = page.locator('iframe[title*="Interactive demo"]')
|
||||
await expect(iframe).toBeAttached()
|
||||
})
|
||||
|
||||
test('demo detail page has transcript section', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(
|
||||
page.getByRole('button', { name: /demo transcript/i })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('demo detail page has next demo navigation', async ({ page }) => {
|
||||
await page.goto('/demos/image-to-video')
|
||||
await expect(page.getByText(/what's next/i)).toBeVisible()
|
||||
})
|
||||
|
||||
test('demo library page renders', async ({ page }) => {
|
||||
await page.goto('/demos')
|
||||
await expect(page.getByText('Coming Soon')).toBeVisible()
|
||||
})
|
||||
|
||||
test('non-existent demo returns 404', async ({ page }) => {
|
||||
const response = await page.goto('/demos/nonexistent')
|
||||
expect(response?.status()).toBe(404)
|
||||
})
|
||||
|
||||
test('zh-CN demo page renders localized content', async ({ page }) => {
|
||||
await page.goto('/zh-CN/demos/image-to-video')
|
||||
await expect(page.getByRole('heading', { level: 1 })).toContainText(
|
||||
'从图片创建视频'
|
||||
)
|
||||
const nextDemoLink = page.locator('a[href*="/zh-CN/demos/"]').first()
|
||||
await expect(nextDemoLink).toBeAttached()
|
||||
})
|
||||
})
|
||||
@@ -61,7 +61,7 @@ test.describe('Payment failed page @smoke', () => {
|
||||
test('shows failure heading and subtitle', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', {
|
||||
name: /Payment was not completed/i,
|
||||
name: /Unable to complete payment/i,
|
||||
level: 1
|
||||
})
|
||||
).toBeVisible()
|
||||
@@ -102,7 +102,7 @@ test.describe('Payment pages zh-CN @smoke', () => {
|
||||
await expect(page).toHaveTitle('支付失败 — Comfy')
|
||||
await expectNoIndex(page)
|
||||
await expect(
|
||||
page.getByRole('heading', { name: '支付未完成', level: 1 })
|
||||
page.getByRole('heading', { name: '无法完成支付', level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: '联系支持' })).toHaveAttribute(
|
||||
'href',
|
||||
|
||||
BIN
apps/website/public/images/demos/image-to-video-og.png
Normal file
BIN
apps/website/public/images/demos/image-to-video-og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
BIN
apps/website/public/images/demos/image-to-video-thumb.webp
Normal file
BIN
apps/website/public/images/demos/image-to-video-thumb.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
BIN
apps/website/public/images/demos/workflow-templates-og.png
Normal file
BIN
apps/website/public/images/demos/workflow-templates-og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
BIN
apps/website/public/images/demos/workflow-templates-thumb.webp
Normal file
BIN
apps/website/public/images/demos/workflow-templates-thumb.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 B |
@@ -31,4 +31,28 @@ Disallow: /_website/
|
||||
Disallow: /_vercel/
|
||||
Disallow: /payment/
|
||||
|
||||
User-agent: GPTBot
|
||||
Allow: /
|
||||
|
||||
User-agent: OAI-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Allow: /
|
||||
|
||||
User-agent: ClaudeBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-User
|
||||
Allow: /
|
||||
|
||||
User-agent: Claude-SearchBot
|
||||
Allow: /
|
||||
|
||||
User-agent: PerplexityBot
|
||||
Allow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://comfy.org/sitemap-index.xml
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const photos = [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team0.webp',
|
||||
@@ -17,45 +15,34 @@ const photos = [
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team3.webp',
|
||||
alt: 'Team on a boat'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team4.webp',
|
||||
alt: 'Teammates posing at a restaurant'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team5.webp',
|
||||
alt: 'Teammates at a social gathering'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team6.webp',
|
||||
alt: 'Team sailing at golden hour'
|
||||
},
|
||||
{
|
||||
src: 'https://media.comfy.org/website/careers/team7.webp',
|
||||
alt: 'Team on a sailboat at sunset'
|
||||
}
|
||||
]
|
||||
|
||||
const loopedPhotos = [...photos, ...photos, ...photos]
|
||||
|
||||
const scrollRef = ref<HTMLElement>()
|
||||
|
||||
function onScroll() {
|
||||
const el = scrollRef.value
|
||||
if (!el) return
|
||||
|
||||
const third = el.scrollWidth / 3
|
||||
const maxScroll = el.scrollWidth - el.clientWidth
|
||||
|
||||
if (el.scrollLeft >= maxScroll - 1) {
|
||||
el.scrollLeft -= third
|
||||
} else if (el.scrollLeft <= 1) {
|
||||
el.scrollLeft += third
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = scrollRef.value
|
||||
if (el) {
|
||||
el.scrollLeft = el.scrollWidth / 3
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="py-12 md:py-24">
|
||||
<div
|
||||
ref="scrollRef"
|
||||
class="flex gap-4 overflow-x-auto px-6 md:gap-6 md:px-20"
|
||||
style="scrollbar-width: none"
|
||||
@scroll="onScroll"
|
||||
>
|
||||
<div
|
||||
v-for="(photo, i) in loopedPhotos"
|
||||
v-for="(photo, i) in photos"
|
||||
:key="i"
|
||||
class="aspect-3/4 h-64 shrink-0 md:h-96"
|
||||
>
|
||||
|
||||
67
apps/website/src/components/demos/ArcadeEmbed.vue
Normal file
67
apps/website/src/components/demos/ArcadeEmbed.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
arcadeId,
|
||||
title,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
arcadeId: string
|
||||
title: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const loaded = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-4 py-8 lg:px-20 lg:py-16"
|
||||
:aria-label="t('demos.embed.label', locale)"
|
||||
>
|
||||
<div
|
||||
class="relative mx-auto aspect-video max-w-6xl overflow-hidden rounded-4xl border border-white/10"
|
||||
>
|
||||
<div
|
||||
v-if="!loaded"
|
||||
aria-hidden="true"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-black/50"
|
||||
>
|
||||
<div
|
||||
class="border-primary-comfy-canvas/60 mb-4 size-10 animate-pulse rounded-full border-2"
|
||||
/>
|
||||
<p class="text-primary-warm-gray text-sm">
|
||||
{{ t('demos.loading', locale) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
class="size-full"
|
||||
:src="`https://demo.arcade.software/${arcadeId}?embed&show_title=0`"
|
||||
:title="`${t('demos.embed.label', locale)}: ${title}`"
|
||||
loading="lazy"
|
||||
allow="clipboard-write"
|
||||
referrerpolicy="strict-origin-when-cross-origin"
|
||||
@load="loaded = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<p class="text-primary-warm-gray mt-4 text-sm">
|
||||
{{ t('demos.noscript', locale) }}
|
||||
<a
|
||||
class="text-primary-comfy-yellow ml-2 underline"
|
||||
:href="`https://demo.arcade.software/${arcadeId}`"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ t('demos.noscript.link', locale) }}
|
||||
</a>
|
||||
</p>
|
||||
</noscript>
|
||||
</section>
|
||||
</template>
|
||||
60
apps/website/src/components/demos/DemoHeroSection.vue
Normal file
60
apps/website/src/components/demos/DemoHeroSection.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
label,
|
||||
title,
|
||||
description,
|
||||
difficulty,
|
||||
estimatedTime,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
label: string
|
||||
title: string
|
||||
description: string
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
estimatedTime: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const difficultyKey = `demos.difficulty.${difficulty}` as TranslationKey
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pt-16 lg:px-20 lg:pt-40 lg:pb-8">
|
||||
<div class="mx-auto flex max-w-4xl flex-col items-center text-center">
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-xs font-semibold tracking-widest uppercase"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
|
||||
<h1
|
||||
class="text-primary-comfy-canvas mt-4 text-3xl/tight font-light lg:text-5xl/tight"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="text-primary-warm-gray mt-6 max-w-xl text-sm/relaxed lg:text-base/relaxed"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-wrap justify-center gap-3">
|
||||
<span
|
||||
class="bg-transparency-white-t4 text-primary-comfy-canvas rounded-full px-3 py-1 text-xs font-semibold tracking-wide uppercase"
|
||||
>
|
||||
{{ t(difficultyKey, locale) }}
|
||||
</span>
|
||||
<span
|
||||
class="bg-transparency-white-t4 text-primary-comfy-canvas rounded-full px-3 py-1 text-xs font-semibold"
|
||||
>
|
||||
{{ t(estimatedTime as TranslationKey, locale) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
59
apps/website/src/components/demos/DemoNavSection.vue
Normal file
59
apps/website/src/components/demos/DemoNavSection.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const {
|
||||
nextTitle,
|
||||
nextSlug,
|
||||
nextThumbnail,
|
||||
locale = 'en'
|
||||
} = defineProps<{
|
||||
nextTitle: string
|
||||
nextSlug: string
|
||||
nextThumbnail: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const localePrefix = locale === 'en' ? '' : `/${locale}`
|
||||
const nextHref = `${localePrefix}/demos/${nextSlug}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-4 py-16 lg:px-20 lg:py-24">
|
||||
<h2 class="text-primary-comfy-canvas mb-10 text-2xl font-light lg:text-3xl">
|
||||
{{ t('demos.nav.nextDemo' as TranslationKey, locale) }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="bg-transparency-white-t4 rounded-5xl mx-auto flex flex-col gap-8 p-2 lg:max-w-237.5 lg:flex-row lg:items-center"
|
||||
>
|
||||
<a :href="nextHref" class="shrink-0 lg:w-1/2">
|
||||
<img
|
||||
:src="nextThumbnail"
|
||||
:alt="nextTitle"
|
||||
class="w-full rounded-4xl object-cover"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<h3 class="text-primary-comfy-canvas text-xl font-light lg:text-2xl">
|
||||
{{ nextTitle }}
|
||||
</h3>
|
||||
|
||||
<a :href="nextHref" class="flex items-center gap-3">
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink flex size-10 items-center justify-center rounded-full"
|
||||
>
|
||||
<span class="text-lg font-bold">›</span>
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-canvas ppformula-text-center text-sm font-semibold tracking-wider uppercase"
|
||||
>
|
||||
{{ t('demos.nav.viewDemo' as TranslationKey, locale) }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
50
apps/website/src/components/demos/DemoTranscript.vue
Normal file
50
apps/website/src/components/demos/DemoTranscript.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { transcript, locale = 'en' } = defineProps<{
|
||||
transcript: string
|
||||
locale?: Locale
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-4 py-8 lg:px-20 lg:py-12"
|
||||
:aria-label="t('demos.transcript.label', locale)"
|
||||
>
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-comfy-canvas text-left"
|
||||
:aria-expanded="expanded"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<span class="text-sm font-semibold tracking-wide uppercase">
|
||||
{{ t('demos.transcript.label', locale) }}
|
||||
</span>
|
||||
<span class="text-primary-warm-gray ml-2 text-xs">
|
||||
{{ t('demos.transcript.note', locale) }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
role="region"
|
||||
:aria-label="t('demos.transcript.label', locale)"
|
||||
:class="
|
||||
cn(
|
||||
expanded ? 'mt-4' : 'sr-only',
|
||||
'text-primary-warm-gray text-sm/relaxed'
|
||||
)
|
||||
"
|
||||
v-html="transcript"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
68
apps/website/src/config/demos.ts
Normal file
68
apps/website/src/config/demos.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { TranslationKey } from '../i18n/translations'
|
||||
|
||||
interface Demo {
|
||||
readonly slug: string
|
||||
readonly arcadeId: string
|
||||
readonly category: TranslationKey
|
||||
readonly title: TranslationKey
|
||||
readonly description: TranslationKey
|
||||
readonly ogImage: string
|
||||
readonly thumbnail: string
|
||||
readonly estimatedTime: TranslationKey
|
||||
readonly durationIso: string
|
||||
readonly difficulty: 'beginner' | 'intermediate' | 'advanced'
|
||||
readonly tags: readonly string[]
|
||||
readonly transcript?: TranslationKey
|
||||
readonly publishedDate: string
|
||||
readonly modifiedDate: string
|
||||
}
|
||||
|
||||
export const demos: readonly Demo[] = [
|
||||
{
|
||||
slug: 'image-to-video',
|
||||
arcadeId: 'F3CTalnGnR4R0qJIVMNX',
|
||||
category: 'demos.category.templates',
|
||||
title: 'demos.image-to-video.title',
|
||||
description: 'demos.image-to-video.description',
|
||||
transcript: 'demos.image-to-video.transcript',
|
||||
ogImage: '/images/demos/image-to-video-og.png',
|
||||
thumbnail: '/images/demos/image-to-video-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['templates', 'image', 'video'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19'
|
||||
},
|
||||
{
|
||||
slug: 'workflow-templates',
|
||||
arcadeId: 'KhqcXDElnFWklo7ACBqE',
|
||||
category: 'demos.category.gettingStarted',
|
||||
title: 'demos.workflow-templates.title',
|
||||
description: 'demos.workflow-templates.description',
|
||||
transcript: 'demos.workflow-templates.transcript',
|
||||
ogImage: '/images/demos/workflow-templates-og.png',
|
||||
thumbnail: '/images/demos/workflow-templates-thumb.webp',
|
||||
estimatedTime: 'demos.duration.2min',
|
||||
durationIso: 'PT2M',
|
||||
difficulty: 'beginner',
|
||||
tags: ['getting-started', 'templates', 'workflow'],
|
||||
publishedDate: '2026-04-19',
|
||||
modifiedDate: '2026-04-19'
|
||||
}
|
||||
]
|
||||
|
||||
export function getDemoBySlug(slug: string): Demo | undefined {
|
||||
return demos.find((demo) => demo.slug === slug)
|
||||
}
|
||||
|
||||
export function getNextDemo(slug: string): Demo {
|
||||
if (demos.length === 0) {
|
||||
throw new Error('No demos configured')
|
||||
}
|
||||
const index = demos.findIndex((demo) => demo.slug === slug)
|
||||
if (index === -1) {
|
||||
throw new Error(`Unknown demo slug: ${slug}`)
|
||||
}
|
||||
return demos[(index + 1) % demos.length]
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const baseRoutes = {
|
||||
about: '/about',
|
||||
careers: '/careers',
|
||||
customers: '/customers',
|
||||
demos: '/demos',
|
||||
termsOfService: '/terms-of-service',
|
||||
privacyPolicy: '/privacy-policy',
|
||||
contact: '/contact'
|
||||
|
||||
@@ -3498,18 +3498,6 @@ const translations = {
|
||||
en: 'Dale Carman | Co-founder @ Groove Jones',
|
||||
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.2.label': {
|
||||
en: 'GROOVE JONES CONTRIBUTORS',
|
||||
'zh-CN': 'GROOVE JONES 贡献者'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.2.name': {
|
||||
en: 'TBD',
|
||||
'zh-CN': '待补充'
|
||||
},
|
||||
'customers.detail.groove-jones.topic-10.block.2.role': {
|
||||
en: 'TBD',
|
||||
'zh-CN': '待补充'
|
||||
},
|
||||
|
||||
// Contact – FormSection
|
||||
'contact.form.badge': {
|
||||
@@ -3542,6 +3530,80 @@ const translations = {
|
||||
'zh-CN': '我们会为您处理请求。'
|
||||
},
|
||||
|
||||
'demos.category.templates': { en: 'TEMPLATES', 'zh-CN': '模板' },
|
||||
'demos.category.gettingStarted': { en: 'GETTING STARTED', 'zh-CN': '入门' },
|
||||
|
||||
'demos.image-to-video.title': {
|
||||
en: 'Create a Video from an Image',
|
||||
'zh-CN': '从图片创建视频'
|
||||
},
|
||||
'demos.image-to-video.description': {
|
||||
en: 'Learn how to use the Image to Video workflow template in ComfyUI to generate short video clips from a single image.',
|
||||
'zh-CN':
|
||||
'了解如何使用 ComfyUI 中的图片转视频工作流模板,从单张图片生成短视频。'
|
||||
},
|
||||
'demos.image-to-video.transcript': {
|
||||
en: '<ol><li><strong>Open ComfyUI</strong> — Launch the application and you\'ll see the node-based workflow canvas where all your AI pipelines are built.</li><li><strong>Browse templates</strong> — Click the workflow templates button in the sidebar to browse available starting points.</li><li><strong>Select Image to Video</strong> — Find and select the "Image to Video" template from the list to load it onto your canvas.</li><li><strong>Upload your image</strong> — Click the image upload node and select the source image you want to animate.</li><li><strong>Run the workflow</strong> — Click the "Queue" button to execute the workflow and generate your video output.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开 ComfyUI</strong> — 启动应用程序,您将看到基于节点的工作流画布。</li><li><strong>浏览模板</strong> — 点击侧栏中的工作流模板按钮,浏览可用模板。</li><li><strong>选择图片转视频</strong> — 从列表中找到并选择"图片转视频"模板。</li><li><strong>上传图片</strong> — 点击图片上传节点,选择要动画化的源图片。</li><li><strong>运行工作流</strong> — 点击"排队"按钮执行工作流并生成视频输出。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.workflow-templates.title': {
|
||||
en: 'Browse Workflow Templates',
|
||||
'zh-CN': '浏览工作流模板'
|
||||
},
|
||||
'demos.workflow-templates.description': {
|
||||
en: "Explore ComfyUI's built-in workflow templates to quickly get started with common AI generation tasks.",
|
||||
'zh-CN': '探索 ComfyUI 内置的工作流模板,快速开始常见的 AI 生成任务。'
|
||||
},
|
||||
'demos.workflow-templates.transcript': {
|
||||
en: '<ol><li><strong>Open the template browser</strong> — Click the templates icon in the ComfyUI sidebar to open the template library.</li><li><strong>Browse categories</strong> — Templates are organized by task: image generation, video, upscaling, and more.</li><li><strong>Preview a template</strong> — Hover over any template to see a preview of its workflow and expected output.</li><li><strong>Load and customize</strong> — Click to load a template, then modify parameters to fit your needs.</li></ol>',
|
||||
'zh-CN':
|
||||
'<ol><li><strong>打开模板浏览器</strong> — 点击 ComfyUI 侧栏中的模板图标。</li><li><strong>浏览分类</strong> — 模板按任务分类:图像生成、视频、放大等。</li><li><strong>预览模板</strong> — 将鼠标悬停在模板上查看预览。</li><li><strong>加载并自定义</strong> — 点击加载模板,然后修改参数。</li></ol>'
|
||||
},
|
||||
|
||||
'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
|
||||
'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
|
||||
'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
|
||||
'demos.transcript.label': { en: 'Demo transcript', 'zh-CN': '演示文字记录' },
|
||||
'demos.transcript.note': {
|
||||
en: '(for accessibility & search)',
|
||||
'zh-CN': '(无障碍和搜索)'
|
||||
},
|
||||
'demos.loading': {
|
||||
en: 'Loading interactive demo…',
|
||||
'zh-CN': '正在加载互动演示…'
|
||||
},
|
||||
'demos.noscript': {
|
||||
en: 'This interactive demo requires JavaScript.',
|
||||
'zh-CN': '此互动演示需要 JavaScript。'
|
||||
},
|
||||
'demos.noscript.link': {
|
||||
en: 'View on Arcade →',
|
||||
'zh-CN': '在 Arcade 上查看 →'
|
||||
},
|
||||
'demos.duration.2min': { en: '~2 min', 'zh-CN': '~2 分钟' },
|
||||
'demos.difficulty.beginner': { en: 'Beginner', 'zh-CN': '入门' },
|
||||
'demos.difficulty.intermediate': {
|
||||
en: 'Intermediate',
|
||||
'zh-CN': '中级'
|
||||
},
|
||||
'demos.difficulty.advanced': { en: 'Advanced', 'zh-CN': '高级' },
|
||||
'demos.embed.label': {
|
||||
en: 'Interactive demo',
|
||||
'zh-CN': '互动演示'
|
||||
},
|
||||
'demos.comingSoon.title': {
|
||||
en: 'Coming Soon',
|
||||
'zh-CN': '即将推出'
|
||||
},
|
||||
'demos.comingSoon.body': {
|
||||
en: 'This page is being redesigned. Check back soon.',
|
||||
'zh-CN': '此页面正在重新设计中,请稍后再来。'
|
||||
},
|
||||
'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
|
||||
'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
|
||||
|
||||
'customers.story.whatsNext': {
|
||||
en: "What's next?",
|
||||
'zh-CN': '接下来看什么?'
|
||||
@@ -3620,8 +3682,8 @@ const translations = {
|
||||
'zh-CN': '支付'
|
||||
},
|
||||
'payment.failed.title': {
|
||||
en: 'Payment was not completed',
|
||||
'zh-CN': '支付未完成'
|
||||
en: 'Unable to complete payment',
|
||||
'zh-CN': '无法完成支付'
|
||||
},
|
||||
'payment.failed.subtitle': {
|
||||
en: "Your payment didn't go through and you have not been charged. Reach out to support or read the subscription docs if you need help.",
|
||||
|
||||
@@ -10,6 +10,7 @@ import { fetchGitHubStars, formatStarCount } from '../utils/github'
|
||||
interface Props {
|
||||
title: string
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
ogImage?: string
|
||||
noindex?: boolean
|
||||
}
|
||||
@@ -17,10 +18,13 @@ interface Props {
|
||||
const {
|
||||
title,
|
||||
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
|
||||
keywords,
|
||||
ogImage = 'https://media.comfy.org/website/comfy.webp',
|
||||
noindex = false,
|
||||
} = Astro.props
|
||||
|
||||
const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
|
||||
|
||||
const siteBase = Astro.site ?? 'https://comfy.org'
|
||||
const canonicalURL = new URL(Astro.url.pathname, siteBase)
|
||||
const ogImageURL = new URL(ogImage, siteBase)
|
||||
@@ -62,6 +66,7 @@ const websiteJsonLd = {
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content={description} />
|
||||
{keywordsContent && <meta name="keywords" content={keywordsContent} />}
|
||||
{noindex && <meta name="robots" content="noindex, nofollow" />}
|
||||
<title>{title}</title>
|
||||
|
||||
@@ -109,6 +114,7 @@ const websiteJsonLd = {
|
||||
)}
|
||||
|
||||
<ClientRouter />
|
||||
<slot name="head" />
|
||||
</head>
|
||||
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
|
||||
{gtmEnabled && (
|
||||
|
||||
@@ -7,9 +7,14 @@ import AudienceSection from '../../components/product/cloud/AudienceSection.vue'
|
||||
import PricingSection from '../../components/product/cloud/PricingSection.vue'
|
||||
import ProductCardsSection from '../../components/product/cloud/ProductCardsSection.vue'
|
||||
import FAQSection from '../../components/product/cloud/FAQSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy Cloud — AI in the Cloud">
|
||||
<BaseLayout
|
||||
title="Comfy Cloud — AI in the Cloud"
|
||||
description={t('cloud.hero.subtitle', 'en')}
|
||||
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'comfy cloud', 'comfy ui application', 'comfyui browser', 'cloud comfyui', 'managed comfyui']}
|
||||
>
|
||||
<HeroSection />
|
||||
<ReasonSection />
|
||||
<AIModelsSection />
|
||||
|
||||
139
apps/website/src/pages/demos/[slug].astro
Normal file
139
apps/website/src/pages/demos/[slug].astro
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import DemoHeroSection from '../../components/demos/DemoHeroSection.vue'
|
||||
import ArcadeEmbed from '../../components/demos/ArcadeEmbed.vue'
|
||||
import DemoTranscript from '../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../config/demos'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
params: { slug: demo.slug }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.params
|
||||
const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title)
|
||||
const description = t(demo.description)
|
||||
const canonicalURL = new URL(`/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home'),
|
||||
item: 'https://comfy.org'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos'),
|
||||
item: 'https://comfy.org/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
<DemoHeroSection
|
||||
label={t(demo.category)}
|
||||
title={title}
|
||||
description={description}
|
||||
difficulty={demo.difficulty}
|
||||
estimatedTime={demo.estimatedTime}
|
||||
/>
|
||||
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
client:load
|
||||
/>
|
||||
|
||||
{demo.transcript && (
|
||||
<DemoTranscript
|
||||
transcript={t(demo.transcript)}
|
||||
client:visible
|
||||
/>
|
||||
)}
|
||||
|
||||
<DemoNavSection
|
||||
nextTitle={t(nextDemo.title)}
|
||||
nextSlug={nextDemo.slug}
|
||||
nextThumbnail={nextDemo.thumbnail}
|
||||
/>
|
||||
</BaseLayout>
|
||||
8
apps/website/src/pages/demos/index.astro
Normal file
8
apps/website/src/pages/demos/index.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import ComingSoon from '../../components/common/ComingSoon.astro'
|
||||
---
|
||||
|
||||
<BaseLayout title="Demos — Comfy" description="Interactive demos and tutorials for ComfyUI.">
|
||||
<ComingSoon />
|
||||
</BaseLayout>
|
||||
@@ -7,9 +7,14 @@ import ReasonSection from '../components/product/local/ReasonSection.vue'
|
||||
import EcoSystemSection from '../components/product/local/EcoSystemSection.vue'
|
||||
import ProductCardsSection from '../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../components/product/local/FAQSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="Download Comfy — Run AI Locally">
|
||||
<BaseLayout
|
||||
title="Download Comfy — Run AI Locally"
|
||||
description={t('download.hero.subtitle', 'en')}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfy ui application', 'comfyui download', 'download comfyui', 'comfyui windows', 'comfyui mac', 'comfyui linux', 'comfyui local']}
|
||||
>
|
||||
<CloudBannerSection />
|
||||
<HeroSection client:load />
|
||||
<ReasonSection />
|
||||
|
||||
@@ -8,9 +8,14 @@ import UseCaseSection from '../components/home/UseCaseSection.vue'
|
||||
import CaseStudySpotlightSection from '../components/home/CaseStudySpotlightSection.vue'
|
||||
import GetStartedSection from '../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../components/home/BuildWhatSection.vue'
|
||||
import { t } from '../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — Professional Control of Visual AI">
|
||||
<BaseLayout
|
||||
title="Comfy — Professional Control of Visual AI"
|
||||
description={t('hero.subtitle', 'en')}
|
||||
keywords={['comfyui app', 'comfyui web app', 'comfy ui application', 'comfyui application', 'comfy app', 'comfyui', 'visual ai app', 'node-based ai', 'generative ai workflows']}
|
||||
>
|
||||
<HeroSection client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection client:load />
|
||||
|
||||
@@ -7,9 +7,14 @@ import AudienceSection from '../../../components/product/cloud/AudienceSection.v
|
||||
import PricingSection from '../../../components/product/cloud/PricingSection.vue'
|
||||
import ProductCardsSection from '../../../components/product/cloud/ProductCardsSection.vue'
|
||||
import FAQSection from '../../../components/product/cloud/FAQSection.vue'
|
||||
import { t } from '../../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy Cloud — 云端 AI">
|
||||
<BaseLayout
|
||||
title="Comfy Cloud — 云端 AI"
|
||||
description={t('cloud.hero.subtitle', 'zh-CN')}
|
||||
keywords={['comfyui web app', 'comfyui app', 'comfyui online', 'comfyui cloud', 'ComfyUI 网页版', 'ComfyUI 云端', 'ComfyUI 应用', 'Comfy Cloud', '云端 ComfyUI']}
|
||||
>
|
||||
<HeroSection locale="zh-CN" />
|
||||
<ReasonSection locale="zh-CN" />
|
||||
<AIModelsSection locale="zh-CN" />
|
||||
|
||||
143
apps/website/src/pages/zh-CN/demos/[slug].astro
Normal file
143
apps/website/src/pages/zh-CN/demos/[slug].astro
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
import type { GetStaticPaths } from 'astro'
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import DemoHeroSection from '../../../components/demos/DemoHeroSection.vue'
|
||||
import ArcadeEmbed from '../../../components/demos/ArcadeEmbed.vue'
|
||||
import DemoTranscript from '../../../components/demos/DemoTranscript.vue'
|
||||
import DemoNavSection from '../../../components/demos/DemoNavSection.vue'
|
||||
import { demos, getDemoBySlug, getNextDemo } from '../../../config/demos'
|
||||
import { t } from '../../../i18n/translations'
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = () => {
|
||||
return demos.map((demo) => ({
|
||||
params: { slug: demo.slug }
|
||||
}))
|
||||
}
|
||||
|
||||
const { slug } = Astro.params
|
||||
const demo = getDemoBySlug(slug as string)!
|
||||
const nextDemo = getNextDemo(slug as string)
|
||||
const title = t(demo.title, 'zh-CN')
|
||||
const description = t(demo.description, 'zh-CN')
|
||||
const canonicalURL = new URL(`/zh-CN/demos/${demo.slug}`, Astro.site)
|
||||
|
||||
const howToJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'HowTo',
|
||||
name: title,
|
||||
description,
|
||||
image: new URL(demo.ogImage, Astro.site).href,
|
||||
totalTime: demo.durationIso,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const learningResourceJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'LearningResource',
|
||||
name: title,
|
||||
description,
|
||||
learningResourceType: 'interactive tutorial',
|
||||
interactivityType: 'active',
|
||||
educationalLevel: demo.difficulty === 'beginner'
|
||||
? 'Beginner'
|
||||
: demo.difficulty === 'intermediate'
|
||||
? 'Intermediate'
|
||||
: 'Advanced',
|
||||
url: canonicalURL.href,
|
||||
datePublished: demo.publishedDate,
|
||||
dateModified: demo.modifiedDate,
|
||||
author: {
|
||||
'@type': 'Organization',
|
||||
name: 'Comfy Org',
|
||||
url: 'https://comfy.org'
|
||||
}
|
||||
}
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: t('demos.breadcrumb.home', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: t('demos.breadcrumb.demos', 'zh-CN'),
|
||||
item: 'https://comfy.org/zh-CN/demos'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: title
|
||||
}
|
||||
]
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={`${title} — Comfy`}
|
||||
description={description}
|
||||
ogImage={demo.ogImage}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<meta property="article:published_time" content={demo.publishedDate} />
|
||||
<meta property="article:modified_time" content={demo.modifiedDate} />
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(howToJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(learningResourceJsonLd)}
|
||||
/>
|
||||
<script
|
||||
is:inline
|
||||
type="application/ld+json"
|
||||
set:html={JSON.stringify(breadcrumbJsonLd)}
|
||||
/>
|
||||
<link rel="preconnect" href="https://demo.arcade.software" />
|
||||
</Fragment>
|
||||
|
||||
<DemoHeroSection
|
||||
label={t(demo.category, 'zh-CN')}
|
||||
title={title}
|
||||
description={description}
|
||||
difficulty={demo.difficulty}
|
||||
estimatedTime={demo.estimatedTime}
|
||||
locale="zh-CN"
|
||||
/>
|
||||
|
||||
<ArcadeEmbed
|
||||
arcadeId={demo.arcadeId}
|
||||
title={title}
|
||||
locale="zh-CN"
|
||||
client:load
|
||||
/>
|
||||
|
||||
{demo.transcript && (
|
||||
<DemoTranscript
|
||||
transcript={t(demo.transcript, 'zh-CN')}
|
||||
locale="zh-CN"
|
||||
client:visible
|
||||
/>
|
||||
)}
|
||||
|
||||
<DemoNavSection
|
||||
nextTitle={t(nextDemo.title, 'zh-CN')}
|
||||
nextSlug={nextDemo.slug}
|
||||
nextThumbnail={nextDemo.thumbnail}
|
||||
locale="zh-CN"
|
||||
/>
|
||||
</BaseLayout>
|
||||
17
apps/website/src/pages/zh-CN/demos/index.astro
Normal file
17
apps/website/src/pages/zh-CN/demos/index.astro
Normal file
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import BaseLayout from '../../../layouts/BaseLayout.astro'
|
||||
import { t } from '../../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="演示 — Comfy" description="ComfyUI 的互动演示和教程。">
|
||||
<section class="flex min-h-[60vh] items-center justify-center px-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-primary-comfy-canvas text-4xl font-light">
|
||||
{t('demos.comingSoon.title', 'zh-CN')}
|
||||
</h1>
|
||||
<p class="text-primary-warm-gray mt-4 text-sm">
|
||||
{t('demos.comingSoon.body', 'zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
@@ -7,9 +7,14 @@ import ReasonSection from '../../components/product/local/ReasonSection.vue'
|
||||
import EcoSystemSection from '../../components/product/local/EcoSystemSection.vue'
|
||||
import ProductCardsSection from '../../components/product/local/ProductCardsSection.vue'
|
||||
import FAQSection from '../../components/product/local/FAQSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="下载 — Comfy">
|
||||
<BaseLayout
|
||||
title="下载 — Comfy"
|
||||
description={t('download.hero.subtitle', 'zh-CN')}
|
||||
keywords={['comfyui app', 'comfyui desktop app', 'comfyui download', 'ComfyUI 下载', 'ComfyUI 桌面应用', 'ComfyUI 应用', 'ComfyUI Windows', 'ComfyUI macOS', 'ComfyUI Linux']}
|
||||
>
|
||||
<CloudBannerSection locale="zh-CN" />
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<ReasonSection locale="zh-CN" />
|
||||
|
||||
@@ -8,9 +8,14 @@ import UseCaseSection from '../../components/home/UseCaseSection.vue'
|
||||
import CaseStudySpotlightSection from '../../components/home/CaseStudySpotlightSection.vue'
|
||||
import GetStartedSection from '../../components/home/GetStartedSection.vue'
|
||||
import BuildWhatSection from '../../components/home/BuildWhatSection.vue'
|
||||
import { t } from '../../i18n/translations'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的最强可控性">
|
||||
<BaseLayout
|
||||
title="Comfy — 视觉 AI 的最强可控性"
|
||||
description={t('hero.subtitle', 'zh-CN')}
|
||||
keywords={['comfyui app', 'comfyui web app', 'comfyui application', 'ComfyUI 应用', 'ComfyUI 网页版', 'ComfyUI 桌面应用', 'ComfyUI 下载', '可视化 AI', '节点式 AI', '生成式 AI 工作流']}
|
||||
>
|
||||
<HeroSection locale="zh-CN" client:load />
|
||||
<SocialProofBarSection />
|
||||
<ProductShowcaseSection locale="zh-CN" client:load />
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"id": "14af6003-d4ee-4dee-8e3d-cbff2e5519b3",
|
||||
"revision": 0,
|
||||
"last_node_id": 205,
|
||||
"last_link_id": 383,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 205,
|
||||
"type": "821645cc-a5d2-468f-990c-17d9de2e0d1b",
|
||||
"pos": [4720, 5820],
|
||||
"size": [400, 470],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"label": "lotus_model",
|
||||
"name": "unet_name_1",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "unet_name_1"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["76", "unet_name"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "821645cc-a5d2-468f-990c-17d9de2e0d1b",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 8,
|
||||
"lastNodeId": 205,
|
||||
"lastLinkId": 383,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Depth to Image (Z-Image-Turbo)",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [28, 4936, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1599, 4936, 128, 68]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "80e6915f-5d59-4d6b-a197-d8c565ad2922",
|
||||
"name": "unet_name_1",
|
||||
"type": "COMBO",
|
||||
"linkIds": [258],
|
||||
"pos": [132, 4960]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "47f9a22d-6619-4917-9447-a7d5d08dceb5",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [],
|
||||
"pos": [1623, 4960]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 76,
|
||||
"type": "a1134394-29e4-48dc-9b1e-e601a14d6fb8",
|
||||
"pos": [250, 4910],
|
||||
"size": [400, 210],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "unet_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "unet_name"
|
||||
},
|
||||
"link": 258
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"proxyWidgets": [["203", "unet_name"]]
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 258,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 76,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "LG",
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [-30, -4760]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "a1134394-29e4-48dc-9b1e-e601a14d6fb8",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 8,
|
||||
"lastNodeId": 205,
|
||||
"lastLinkId": 383,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Image to Depth Map (Lotus)",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [-60, -173, 128, 68]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [1650, -173, 128, 68]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "d721b249-fd2a-441b-9a78-2805f04e2644",
|
||||
"name": "unet_name",
|
||||
"type": "COMBO",
|
||||
"linkIds": [256],
|
||||
"pos": [44, -149]
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "2ec278bd-0b66-4b30-9c5b-994d5f638214",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"linkIds": [],
|
||||
"pos": [1674, -149]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 203,
|
||||
"type": "UNETLoader",
|
||||
"pos": [180, -200],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "unet_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "unet_name"
|
||||
},
|
||||
"link": 256
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": []
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": ["lotus-depth-d-v1-1.safetensors", "default"]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 256,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 203,
|
||||
"target_slot": 0,
|
||||
"type": "COMBO"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"workflowRendererVersion": "LG",
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [40, 350]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"workflowRendererVersion": "LG",
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [-4500, -5670]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
68
browser_tests/assets/missing/node_replacement_multi.json
Normal file
68
browser_tests/assets/missing/node_replacement_multi.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "E2E_OldSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldSampler" },
|
||||
"widgets_values": [42, 20, 7, "euler", "normal"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "E2E_OldUpscaler",
|
||||
"pos": [500, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "image", "type": "IMAGE", "link": null }],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [2],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldUpscaler" },
|
||||
"widgets_values": ["lanczos", 1.5]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "SaveImage",
|
||||
"pos": [900, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "images", "type": "IMAGE", "link": 2 }],
|
||||
"properties": { "Node name for S&R": "SaveImage" },
|
||||
"widgets_values": ["ComfyUI"]
|
||||
}
|
||||
],
|
||||
"links": [[2, 2, 0, 3, 0, "IMAGE"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
|
||||
"version": 0.4
|
||||
}
|
||||
59
browser_tests/assets/missing/node_replacement_simple.json
Normal file
59
browser_tests/assets/missing/node_replacement_simple.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"last_node_id": 3,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "E2E_OldSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 262],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "model", "type": "MODEL", "link": null },
|
||||
{ "name": "positive", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "negative", "type": "CONDITIONING", "link": null },
|
||||
{ "name": "latent_image", "type": "LATENT", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "E2E_OldSampler" },
|
||||
"widgets_values": [42, 20, 7, "euler", "normal"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEDecode",
|
||||
"pos": [500, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{ "name": "samples", "type": "LATENT", "link": 1 },
|
||||
{ "name": "vae", "type": "VAE", "link": null }
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": { "Node name for S&R": "VAEDecode" },
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [[1, 1, 0, 2, 0, "LATENT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": { "ds": { "scale": 1, "offset": [0, 0] } },
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -460,10 +460,15 @@ export const testComfySnapToGridGridSize = 50
|
||||
const COLLECT_COVERAGE = process.env.COLLECT_COVERAGE === 'true'
|
||||
|
||||
export const comfyPageFixture = base.extend<{
|
||||
initialFeatureFlags: Record<string, unknown>
|
||||
comfyPage: ComfyPage
|
||||
comfyMouse: ComfyMouse
|
||||
comfyFiles: ComfyFiles
|
||||
}>({
|
||||
// Allows configuring feature flags for tests with before initial setup:
|
||||
// `test.use({ initialFeatureFlags: { my_flag: true } })`.
|
||||
initialFeatureFlags: [{}, { option: true }],
|
||||
|
||||
page: async ({ page, browserName }, use) => {
|
||||
if (browserName !== 'chromium' || !COLLECT_COVERAGE) {
|
||||
return use(page)
|
||||
@@ -480,7 +485,7 @@ export const comfyPageFixture = base.extend<{
|
||||
await mcr.add(coverage)
|
||||
},
|
||||
|
||||
comfyPage: async ({ page, request }, use, testInfo) => {
|
||||
comfyPage: async ({ page, request, initialFeatureFlags }, use, testInfo) => {
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
|
||||
const { parallelIndex } = testInfo
|
||||
@@ -505,6 +510,7 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.userId': userId,
|
||||
// Set tutorial completed to true to avoid loading the tutorial workflow.
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.Queue.MaxHistoryItems': 64,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
|
||||
'Comfy.VueNodes.AutoScaleLayout': false,
|
||||
// Disable toast warning about version compatibility, as they may or
|
||||
@@ -523,6 +529,10 @@ export const comfyPageFixture = base.extend<{
|
||||
await comfyPage.cloudAuth.mockAuth()
|
||||
}
|
||||
|
||||
if (Object.keys(initialFeatureFlags).length > 0) {
|
||||
await comfyPage.featureFlags.seedFlags(initialFeatureFlags)
|
||||
}
|
||||
|
||||
await comfyPage.setup()
|
||||
|
||||
if (isVueNodes) {
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { AssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
import { createAssetHelper } from '@e2e/fixtures/helpers/AssetHelper'
|
||||
|
||||
const ASSETS_ROUTE_PATTERN = /\/api\/assets(?:\?.*)?$/
|
||||
const cloudAssetRequestsByPage = new WeakMap<Page, string[]>()
|
||||
|
||||
function makeAssetsResponse(assets: ReadonlyArray<Asset>): ListAssetsResponse {
|
||||
return { assets: [...assets], total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
export function assetRequestIncludesTag(url: string, tag: string): boolean {
|
||||
const includeTags = new URL(url).searchParams.get('include_tags') ?? ''
|
||||
return includeTags
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.includes(tag)
|
||||
}
|
||||
|
||||
export function countAssetRequestsByTag(
|
||||
requests: string[],
|
||||
tag: string
|
||||
): number {
|
||||
return requests.filter((url) => assetRequestIncludesTag(url, tag)).length
|
||||
}
|
||||
|
||||
export const assetApiFixture = base.extend<{
|
||||
assetApi: AssetHelper
|
||||
}>({
|
||||
@@ -14,3 +40,31 @@ export const assetApiFixture = base.extend<{
|
||||
await assetApi.clearMocks()
|
||||
}
|
||||
})
|
||||
|
||||
export function createCloudAssetsFixture(assets: ReadonlyArray<Asset>) {
|
||||
return comfyPageFixture.extend<{
|
||||
cloudAssetRequests: string[]
|
||||
}>({
|
||||
page: async ({ page }, use) => {
|
||||
const cloudAssetRequests: string[] = []
|
||||
cloudAssetRequestsByPage.set(page, cloudAssetRequests)
|
||||
|
||||
async function assetsRouteHandler(route: Route) {
|
||||
cloudAssetRequests.push(route.request().url())
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(assets))
|
||||
})
|
||||
}
|
||||
|
||||
await page.route(ASSETS_ROUTE_PATTERN, assetsRouteHandler)
|
||||
await use(page)
|
||||
await page.unroute(ASSETS_ROUTE_PATTERN, assetsRouteHandler)
|
||||
cloudAssetRequestsByPage.delete(page)
|
||||
},
|
||||
cloudAssetRequests: async ({ page }, use) => {
|
||||
await use(cloudAssetRequestsByPage.get(page) ?? [])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '@e2e/types/globals'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
export class Topbar {
|
||||
private readonly menuLocator: Locator
|
||||
private readonly menuTrigger: Locator
|
||||
readonly newWorkflowButton: Locator
|
||||
readonly workflowTabs: Locator
|
||||
readonly integratedTabBarActions: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.menuLocator = page.locator('.comfy-command-menu')
|
||||
this.menuTrigger = page.locator('.comfy-menu-button-wrapper')
|
||||
this.newWorkflowButton = page.locator('.new-blank-workflow-button')
|
||||
this.workflowTabs = page.getByTestId(TestIds.topbar.workflowTabs)
|
||||
this.integratedTabBarActions = this.workflowTabs.getByTestId(
|
||||
TestIds.topbar.integratedTabBarActions
|
||||
)
|
||||
}
|
||||
|
||||
async getTabNames(): Promise<string[]> {
|
||||
|
||||
47
browser_tests/fixtures/data/nodeReplacements.ts
Normal file
47
browser_tests/fixtures/data/nodeReplacements.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { NodeReplacementResponse } from '@/platform/nodeReplacement/types'
|
||||
|
||||
/**
|
||||
* Mock node replacement mappings for e2e tests.
|
||||
*
|
||||
* Maps fake "missing" node types (E2E_OldSampler, E2E_OldUpscaler) to real
|
||||
* core node types that are always available in the test server.
|
||||
*/
|
||||
export const mockNodeReplacements: NodeReplacementResponse = {
|
||||
E2E_OldSampler: [
|
||||
{
|
||||
new_node_id: 'KSampler',
|
||||
old_node_id: 'E2E_OldSampler',
|
||||
old_widget_ids: ['seed', 'steps', 'cfg', 'sampler_name', 'scheduler'],
|
||||
input_mapping: [
|
||||
{ new_id: 'model', old_id: 'model' },
|
||||
{ new_id: 'positive', old_id: 'positive' },
|
||||
{ new_id: 'negative', old_id: 'negative' },
|
||||
{ new_id: 'latent_image', old_id: 'latent_image' },
|
||||
{ new_id: 'seed', old_id: 'seed' },
|
||||
{ new_id: 'steps', old_id: 'steps' },
|
||||
{ new_id: 'cfg', old_id: 'cfg' },
|
||||
{ new_id: 'sampler_name', old_id: 'sampler_name' },
|
||||
{ new_id: 'scheduler', old_id: 'scheduler' }
|
||||
],
|
||||
output_mapping: [{ new_idx: 0, old_idx: 0 }]
|
||||
}
|
||||
],
|
||||
E2E_OldUpscaler: [
|
||||
{
|
||||
new_node_id: 'ImageScaleBy',
|
||||
old_node_id: 'E2E_OldUpscaler',
|
||||
old_widget_ids: ['upscale_method', 'scale_by'],
|
||||
input_mapping: [
|
||||
{ new_id: 'image', old_id: 'image' },
|
||||
{ new_id: 'upscale_method', old_id: 'upscale_method' },
|
||||
{ new_id: 'scale_by', old_id: 'scale_by' }
|
||||
],
|
||||
output_mapping: [{ new_idx: 0, old_idx: 0 }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** Subset containing only the E2E_OldSampler replacement. */
|
||||
export const mockNodeReplacementsSingle: NodeReplacementResponse = {
|
||||
E2E_OldSampler: mockNodeReplacements.E2E_OldSampler
|
||||
}
|
||||
176
browser_tests/fixtures/helpers/JobsApiMock.ts
Normal file
176
browser_tests/fixtures/helpers/JobsApiMock.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
import type {
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse
|
||||
} from '@comfyorg/ingest-types'
|
||||
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const jobDetailRoutePattern = /\/api\/jobs\/[^/?#]+(?:\?.*)?$/
|
||||
const historyRoutePattern = /\/api\/history(?:\?.*)?$/
|
||||
const defaultJobsListLimit = 100
|
||||
|
||||
export type MockJobRecord = {
|
||||
listItem: JobEntry
|
||||
detail: JobDetailResponse
|
||||
}
|
||||
|
||||
function parsePositiveIntegerParam(url: URL, name: string): number | undefined {
|
||||
const value = Number(url.searchParams.get(name))
|
||||
|
||||
return Number.isInteger(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function getJobIdFromRequest(route: Route): string | null {
|
||||
const url = new URL(route.request().url())
|
||||
const jobId = url.pathname.split('/').at(-1)
|
||||
|
||||
return jobId ? decodeURIComponent(jobId) : null
|
||||
}
|
||||
|
||||
export class JobsApiMock {
|
||||
private listRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private detailRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private historyRouteHandler: ((route: Route) => Promise<void>) | null = null
|
||||
private jobsById = new Map<string, MockJobRecord>()
|
||||
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async mockJobs(jobs: MockJobRecord[]): Promise<void> {
|
||||
this.jobsById = new Map(
|
||||
jobs.map(
|
||||
(job) => [job.listItem.id, job] satisfies [string, MockJobRecord]
|
||||
)
|
||||
)
|
||||
await this.ensureRoutesRegistered()
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.jobsById.clear()
|
||||
|
||||
if (this.listRouteHandler) {
|
||||
await this.page.unroute(jobsListRoutePattern, this.listRouteHandler)
|
||||
this.listRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.detailRouteHandler) {
|
||||
await this.page.unroute(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
this.detailRouteHandler = null
|
||||
}
|
||||
|
||||
if (this.historyRouteHandler) {
|
||||
await this.page.unroute(historyRoutePattern, this.historyRouteHandler)
|
||||
this.historyRouteHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRoutesRegistered(): Promise<void> {
|
||||
if (!this.listRouteHandler) {
|
||||
this.listRouteHandler = async (route: Route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const statuses = url.searchParams
|
||||
.get('status')
|
||||
?.split(',')
|
||||
.map((status) => status.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
let filteredJobs = Array.from(
|
||||
this.jobsById.values(),
|
||||
({ listItem }) => listItem
|
||||
)
|
||||
|
||||
if (statuses?.length) {
|
||||
filteredJobs = filteredJobs.filter((job) =>
|
||||
statuses.includes(job.status)
|
||||
)
|
||||
}
|
||||
|
||||
const offset = parsePositiveIntegerParam(url, 'offset') ?? 0
|
||||
const limit =
|
||||
parsePositiveIntegerParam(url, 'limit') ?? defaultJobsListLimit
|
||||
const total = filteredJobs.length
|
||||
const visibleJobs = filteredJobs.slice(offset, offset + limit)
|
||||
|
||||
const response = {
|
||||
jobs: visibleJobs,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total,
|
||||
has_more: offset + visibleJobs.length < total
|
||||
}
|
||||
} satisfies JobsListResponse
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(response)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobsListRoutePattern, this.listRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.detailRouteHandler) {
|
||||
this.detailRouteHandler = async (route: Route) => {
|
||||
const jobId = getJobIdFromRequest(route)
|
||||
const job = jobId ? this.jobsById.get(jobId) : undefined
|
||||
|
||||
if (!job) {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Job not found' })
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(job.detail)
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(jobDetailRoutePattern, this.detailRouteHandler)
|
||||
}
|
||||
|
||||
if (!this.historyRouteHandler) {
|
||||
this.historyRouteHandler = async (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() !== 'POST') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
|
||||
const requestBody = request.postDataJSON() as
|
||||
| { delete?: string[]; clear?: boolean }
|
||||
| undefined
|
||||
|
||||
if (requestBody?.clear) {
|
||||
this.jobsById = new Map(
|
||||
Array.from(this.jobsById).filter(([, job]) => {
|
||||
const status = job.listItem.status
|
||||
|
||||
return status === 'pending' || status === 'in_progress'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (requestBody?.delete?.length) {
|
||||
for (const jobId of requestBody.delete) {
|
||||
this.jobsById.delete(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
}
|
||||
|
||||
await this.page.route(historyRoutePattern, this.historyRouteHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
136
browser_tests/fixtures/helpers/MaskEditorHelper.ts
Normal file
136
browser_tests/fixtures/helpers/MaskEditorHelper.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Locator } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const MASK_CANVAS_INDEX = 2
|
||||
const RGB_CANVAS_INDEX = 1
|
||||
|
||||
export type BrushSliderLabel = 'thickness'
|
||||
|
||||
export class MaskEditorHelper {
|
||||
constructor(private comfyPage: ComfyPage) {}
|
||||
|
||||
private get page() {
|
||||
return this.comfyPage.page
|
||||
}
|
||||
|
||||
async loadImageOnNode() {
|
||||
await this.comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
const loadImageNode = (
|
||||
await this.comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await this.comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = this.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
async openDialog(): Promise<Locator> {
|
||||
const { imagePreview } = await this.loadImageOnNode()
|
||||
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await this.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = this.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
async drawStrokeOnPointerZone(dialog: Locator) {
|
||||
const pointerZone = dialog.getByTestId('pointer-zone')
|
||||
await expect(pointerZone).toBeVisible()
|
||||
|
||||
const box = await pointerZone.boundingBox()
|
||||
if (!box) throw new Error('Pointer zone bounding box not found')
|
||||
|
||||
const startX = box.x + box.width * 0.3
|
||||
const startY = box.y + box.height * 0.5
|
||||
const endX = box.x + box.width * 0.7
|
||||
const endY = box.y + box.height * 0.5
|
||||
|
||||
await this.page.mouse.move(startX, startY)
|
||||
await this.page.mouse.down()
|
||||
await this.page.mouse.move(endX, endY, { steps: 10 })
|
||||
await this.page.mouse.up()
|
||||
|
||||
return { startX, startY, endX, endY, box }
|
||||
}
|
||||
|
||||
async drawStrokeAndExpectPixels(dialog: Locator) {
|
||||
await this.drawStrokeOnPointerZone(dialog)
|
||||
await expect.poll(() => this.pollMaskPixelCount()).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
getCanvasPixelData(canvasIndex: number) {
|
||||
return this.page.evaluate((idx) => {
|
||||
const canvases = document.querySelectorAll(
|
||||
'#maskEditorCanvasContainer canvas'
|
||||
)
|
||||
const canvas = canvases[idx] as HTMLCanvasElement | undefined
|
||||
if (!canvas) return null
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
const data = ctx.getImageData(0, 0, canvas.width, canvas.height)
|
||||
let nonTransparentPixels = 0
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) nonTransparentPixels++
|
||||
}
|
||||
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
|
||||
}, canvasIndex)
|
||||
}
|
||||
|
||||
pollMaskPixelCount(): Promise<number> {
|
||||
return this.getCanvasPixelData(MASK_CANVAS_INDEX).then(
|
||||
(d) => d?.nonTransparentPixels ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
pollRgbPixelCount(): Promise<number> {
|
||||
return this.getCanvasPixelData(RGB_CANVAS_INDEX).then(
|
||||
(d) => d?.nonTransparentPixels ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
getCanvasSnapshot(canvasIndex: number): Promise<string> {
|
||||
return this.page.evaluate((idx) => {
|
||||
const canvas = document.querySelectorAll(
|
||||
'#maskEditorCanvasContainer canvas'
|
||||
)[idx] as HTMLCanvasElement | undefined
|
||||
return canvas?.toDataURL() ?? ''
|
||||
}, canvasIndex)
|
||||
}
|
||||
|
||||
brushInput(dialog: Locator, label: BrushSliderLabel): Locator {
|
||||
return dialog.getByTestId(`brush-${label}-input`)
|
||||
}
|
||||
}
|
||||
|
||||
export const maskEditorTest = comfyPageFixture.extend<{
|
||||
maskEditor: MaskEditorHelper
|
||||
}>({
|
||||
maskEditor: async ({ comfyPage }, use) => {
|
||||
await use(new MaskEditorHelper(comfyPage))
|
||||
}
|
||||
})
|
||||
93
browser_tests/fixtures/helpers/NodeReplacementHelper.ts
Normal file
93
browser_tests/fixtures/helpers/NodeReplacementHelper.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { NodeReplacementResponse } from '@/platform/nodeReplacement/types'
|
||||
|
||||
/**
|
||||
* Mock `/api/node_replacements` and enable the node replacement feature.
|
||||
*
|
||||
* Unlike features that only consult settings (e.g. shareWorkflowDialog,
|
||||
* managerDialog), node replacement gates on `api.serverFeatureFlags`. The
|
||||
* server sends a `feature_flags` WS message that wholesale replaces
|
||||
* `serverFeatureFlags`, racing with any test-side override done via
|
||||
* `page.evaluate`. To make the flow deterministic across CI shards, this
|
||||
* helper patches `WebSocket.prototype` so every incoming `feature_flags`
|
||||
* message has `node_replacements: true` injected before the api's WS
|
||||
* handler sees it. Reload the page so the patched WebSocket and persisted
|
||||
* settings apply to a fresh app boot, then wait for the resulting
|
||||
* `/api/node_replacements` fetch before returning.
|
||||
*/
|
||||
export async function setupNodeReplacement(
|
||||
comfyPage: ComfyPage,
|
||||
replacements: NodeReplacementResponse
|
||||
): Promise<void> {
|
||||
await comfyPage.page.route('**/api/node_replacements', (route) =>
|
||||
route.fulfill({ json: replacements })
|
||||
)
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.NodeReplacement.Enabled', true)
|
||||
|
||||
await comfyPage.page.addInitScript(() => {
|
||||
const proto = window.WebSocket.prototype
|
||||
const originalAdd = proto.addEventListener
|
||||
proto.addEventListener = function patchedAdd(
|
||||
this: WebSocket,
|
||||
type: string,
|
||||
listener: EventListenerOrEventListenerObject | null,
|
||||
options?: AddEventListenerOptions | boolean
|
||||
) {
|
||||
if (type === 'message' && typeof listener === 'function') {
|
||||
const wrapped = function (this: WebSocket, event: Event) {
|
||||
const msgEvent = event as MessageEvent
|
||||
if (typeof msgEvent.data === 'string') {
|
||||
try {
|
||||
const msg = JSON.parse(msgEvent.data)
|
||||
if (
|
||||
msg &&
|
||||
msg.type === 'feature_flags' &&
|
||||
msg.data &&
|
||||
typeof msg.data === 'object'
|
||||
) {
|
||||
msg.data.node_replacements = true
|
||||
const patched = new MessageEvent('message', {
|
||||
data: JSON.stringify(msg),
|
||||
origin: msgEvent.origin,
|
||||
lastEventId: msgEvent.lastEventId
|
||||
})
|
||||
return (listener as EventListener).call(this, patched)
|
||||
}
|
||||
} catch {
|
||||
// not JSON or not a feature_flags message - pass through
|
||||
}
|
||||
}
|
||||
return (listener as EventListener).call(this, event)
|
||||
}
|
||||
return originalAdd.call(this, type, wrapped as EventListener, options)
|
||||
}
|
||||
return originalAdd.call(
|
||||
this,
|
||||
type,
|
||||
listener as EventListenerOrEventListenerObject,
|
||||
options
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const fetchPromise = comfyPage.page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/api/node_replacements') && response.ok(),
|
||||
{ timeout: 10000 }
|
||||
)
|
||||
|
||||
await comfyPage.workflow.reloadAndWaitForApp()
|
||||
await fetchPromise
|
||||
}
|
||||
|
||||
export function getSwapNodesGroup(page: Page): Locator {
|
||||
return page.getByTestId(TestIds.dialogs.swapNodesGroup)
|
||||
}
|
||||
15
browser_tests/fixtures/jobsApiMockFixture.ts
Normal file
15
browser_tests/fixtures/jobsApiMockFixture.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { test as base } from '@playwright/test'
|
||||
|
||||
import { JobsApiMock } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export const jobsApiMockFixture = base.extend<{
|
||||
jobsApi: JobsApiMock
|
||||
}>({
|
||||
jobsApi: async ({ page }, use) => {
|
||||
const jobsApi = new JobsApiMock(page)
|
||||
|
||||
await use(jobsApi)
|
||||
|
||||
await jobsApi.clear()
|
||||
}
|
||||
})
|
||||
@@ -64,6 +64,7 @@ export const TestIds = {
|
||||
missingModelRefresh: 'missing-model-refresh',
|
||||
missingModelImportUnsupported: 'missing-model-import-unsupported',
|
||||
missingMediaGroup: 'error-group-missing-media',
|
||||
swapNodesGroup: 'error-group-swap-nodes',
|
||||
missingMediaRow: 'missing-media-row',
|
||||
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
|
||||
missingMediaLibrarySelect: 'missing-media-library-select',
|
||||
@@ -90,6 +91,8 @@ export const TestIds = {
|
||||
loginButton: 'login-button',
|
||||
loginButtonPopover: 'login-button-popover',
|
||||
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
|
||||
workflowTabs: 'topbar-workflow-tabs',
|
||||
integratedTabBarActions: 'integrated-tab-bar-actions',
|
||||
actionBarButtons: 'action-bar-buttons'
|
||||
},
|
||||
nodeLibrary: {
|
||||
@@ -210,6 +213,7 @@ export const TestIds = {
|
||||
},
|
||||
queue: {
|
||||
overlayToggle: 'queue-overlay-toggle',
|
||||
jobDetailsPopover: 'queue-job-details-popover',
|
||||
clearHistoryAction: 'clear-history-action',
|
||||
jobAssetsList: 'job-assets-list',
|
||||
notificationBanner: 'queue-notification-banner'
|
||||
|
||||
52
browser_tests/fixtures/utils/jobFixtures.ts
Normal file
52
browser_tests/fixtures/utils/jobFixtures.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { JobDetailResponse, JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { MockJobRecord } from '@e2e/fixtures/helpers/JobsApiMock'
|
||||
|
||||
export function createMockJob(
|
||||
overrides: Partial<JobEntry> & { id: string }
|
||||
): JobEntry {
|
||||
const now = Date.now()
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5_000,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalStatus(status: JobEntry['status']) {
|
||||
return status === 'completed' || status === 'failed' || status === 'cancelled'
|
||||
}
|
||||
|
||||
function createMockJobRecord(listItem: JobEntry): MockJobRecord {
|
||||
const updateTime =
|
||||
listItem.execution_end_time ??
|
||||
listItem.execution_start_time ??
|
||||
listItem.create_time
|
||||
const detail: JobDetailResponse = {
|
||||
...listItem,
|
||||
update_time: updateTime,
|
||||
...(isTerminalStatus(listItem.status) ? { outputs: {} } : {})
|
||||
}
|
||||
|
||||
return {
|
||||
listItem,
|
||||
detail
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockJobRecords(
|
||||
listItems: readonly JobEntry[]
|
||||
): MockJobRecord[] {
|
||||
return listItems.map(createMockJobRecord)
|
||||
}
|
||||
@@ -1,51 +1,20 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Route } from '@playwright/test'
|
||||
|
||||
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
function makeAssetsResponse(assets: Asset[]): ListAssetsResponse {
|
||||
return { assets, total: assets.length, has_more: false }
|
||||
}
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT, STABLE_CHECKPOINT_2]
|
||||
const WAITING_FOR_WIDGET_TYPE = 'waiting:type'
|
||||
const WAITING_FOR_WIDGET_VALUE = 'waiting:value'
|
||||
|
||||
// Stub /api/assets before the app loads. The local ComfyUI backend has no
|
||||
// /api/assets endpoint (returns 503), which poisons the assets store on
|
||||
// first load. Narrow pattern avoids intercepting static /assets/*.js bundles.
|
||||
//
|
||||
// TODO: Consider moving this stub into ComfyPage fixture for all @cloud tests.
|
||||
const test = comfyPageFixture.extend<{
|
||||
cloudAssetRequests: string[]
|
||||
stubCloudAssets: void
|
||||
}>({
|
||||
cloudAssetRequests: async ({ page: _page }, use) => {
|
||||
await use([])
|
||||
},
|
||||
stubCloudAssets: [
|
||||
async ({ cloudAssetRequests, page }, use) => {
|
||||
const pattern = /\/api\/assets(?:\?.*)?$/
|
||||
const assetsRouteHandler = (route: Route) => {
|
||||
cloudAssetRequests.push(route.request().url())
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(makeAssetsResponse(CLOUD_ASSETS))
|
||||
})
|
||||
}
|
||||
await page.route(pattern, assetsRouteHandler)
|
||||
await use()
|
||||
await page.unroute(pattern, assetsRouteHandler)
|
||||
},
|
||||
{ auto: true }
|
||||
]
|
||||
})
|
||||
const test = createCloudAssetsFixture(CLOUD_ASSETS)
|
||||
|
||||
test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
@@ -62,11 +31,9 @@ test.describe('Asset-supported node default value', { tag: '@cloud' }, () => {
|
||||
// new nodes resolve against the cloud asset list after the fetch.
|
||||
await expect
|
||||
.poll(() =>
|
||||
cloudAssetRequests.some((url) => {
|
||||
const includeTags =
|
||||
new URL(url).searchParams.get('include_tags') ?? ''
|
||||
return includeTags.split(',').includes('checkpoints')
|
||||
})
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'checkpoints')
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
|
||||
127
browser_tests/tests/layoutSidebarSettings.spec.ts
Normal file
127
browser_tests/tests/layoutSidebarSettings.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Layout & sidebar settings', { tag: ['@settings'] }, () => {
|
||||
test.describe('Comfy.Sidebar.Size', () => {
|
||||
test('"small" applies small-sidebar class', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
|
||||
await expect(comfyPage.menu.sideToolbar).toContainClass('small-sidebar')
|
||||
})
|
||||
|
||||
test('"normal" does not apply small-sidebar class', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
|
||||
'small-sidebar'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.Sidebar.Style', () => {
|
||||
// `isConnected` overrides the Style setting when the toolbar overflows;
|
||||
// small (48px) items keep content under the default viewport so Style
|
||||
// actually drives rendering.
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'small')
|
||||
})
|
||||
|
||||
test('"connected" applies connected-sidebar class', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'connected')
|
||||
await expect(comfyPage.menu.sideToolbar).toContainClass(
|
||||
'connected-sidebar'
|
||||
)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
|
||||
'floating-sidebar'
|
||||
)
|
||||
})
|
||||
|
||||
test('"floating" applies floating-sidebar class', async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
|
||||
await expect(comfyPage.menu.sideToolbar).toContainClass(
|
||||
'floating-sidebar'
|
||||
)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
|
||||
'connected-sidebar'
|
||||
)
|
||||
})
|
||||
|
||||
test('"floating" + Size "normal" is overridden to connected by overflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
|
||||
await expect(comfyPage.menu.sideToolbar).toContainClass(
|
||||
'connected-sidebar'
|
||||
)
|
||||
await expect(comfyPage.menu.sideToolbar).toContainClass(
|
||||
'overflowing-sidebar'
|
||||
)
|
||||
})
|
||||
|
||||
test('"floating" + Size "normal" renders floating in a viewport tall enough to fit', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Size', 'normal')
|
||||
await comfyPage.settings.setSetting('Comfy.Sidebar.Style', 'floating')
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 1500 })
|
||||
await expect(comfyPage.menu.sideToolbar).toContainClass(
|
||||
'floating-sidebar'
|
||||
)
|
||||
await expect(comfyPage.menu.sideToolbar).not.toContainClass(
|
||||
'overflowing-sidebar'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.UI.TabBarLayout', () => {
|
||||
test('"Default" renders integrated tab bar actions container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Default')
|
||||
await expect(comfyPage.menu.topbar.integratedTabBarActions).toBeAttached()
|
||||
})
|
||||
|
||||
test('"Legacy" does not render integrated tab bar actions container', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UI.TabBarLayout', 'Legacy')
|
||||
await expect(comfyPage.menu.topbar.integratedTabBarActions).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Comfy.TreeExplorer.ItemPadding', () => {
|
||||
// The setting writes a CSS var consumed by .p-tree-node-content,
|
||||
// which only renders in the legacy PrimeVue Tree.
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
})
|
||||
|
||||
test('low padding (0px) is applied to tree node content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.TreeExplorer.ItemPadding', 0)
|
||||
await expect(
|
||||
comfyPage.menu.nodeLibraryTab.nodeLibraryTree
|
||||
.locator('.p-tree-node-content')
|
||||
.first()
|
||||
).toHaveCSS('padding', '0px')
|
||||
})
|
||||
|
||||
test('high padding (8px) is applied to tree node content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.TreeExplorer.ItemPadding', 8)
|
||||
await expect(
|
||||
comfyPage.menu.nodeLibraryTab.nodeLibraryTree
|
||||
.locator('.p-tree-node-content')
|
||||
.first()
|
||||
).toHaveCSS('padding', '8px')
|
||||
})
|
||||
})
|
||||
})
|
||||
32
browser_tests/tests/load3d/load3dLod.spec.ts
Normal file
32
browser_tests/tests/load3d/load3dLod.spec.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
|
||||
|
||||
test.describe('Load3D LOD', () => {
|
||||
test(
|
||||
'canvas pixel dimensions scale with ComfyUI canvas zoom level',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage, load3d }) => {
|
||||
await expect(load3d.canvas).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => load3d.canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const initialWidth = await load3d.canvas.evaluate(
|
||||
(el: HTMLCanvasElement) => el.width
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph!.nodes[0]
|
||||
window.app!.canvas.ds.scale = 2.0
|
||||
node.onResize?.(node.size)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(() => load3d.canvas.evaluate((el: HTMLCanvasElement) => el.width))
|
||||
.toBeGreaterThan(initialWidth)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,117 +1,13 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
|
||||
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
async function loadImageOnNode(comfyPage: ComfyPage) {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
|
||||
|
||||
const loadImageNode = (
|
||||
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
|
||||
)[0]
|
||||
const { x, y } = await loadImageNode.getPosition()
|
||||
|
||||
await comfyPage.dragDrop.dragAndDropFile('image64x64.webp', {
|
||||
dropPosition: { x, y }
|
||||
})
|
||||
|
||||
const imagePreview = comfyPage.page.locator('.image-preview')
|
||||
await expect(imagePreview).toBeVisible()
|
||||
await expect(imagePreview.locator('img')).toBeVisible()
|
||||
await expect(imagePreview).toContainText('x')
|
||||
|
||||
return {
|
||||
imagePreview,
|
||||
nodeId: String(loadImageNode.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function openMaskEditorDialog(comfyPage: ComfyPage) {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
async function getMaskCanvasPixelData(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const canvases = document.querySelectorAll(
|
||||
'#maskEditorCanvasContainer canvas'
|
||||
)
|
||||
// The mask canvas is the 3rd canvas (index 2, z-30)
|
||||
const maskCanvas = canvases[2] as HTMLCanvasElement
|
||||
if (!maskCanvas) return null
|
||||
const ctx = maskCanvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
const data = ctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height)
|
||||
let nonTransparentPixels = 0
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) nonTransparentPixels++
|
||||
}
|
||||
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
|
||||
})
|
||||
}
|
||||
|
||||
function pollMaskPixelCount(page: Page): Promise<number> {
|
||||
return getMaskCanvasPixelData(page).then(
|
||||
(d) => d?.nonTransparentPixels ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
async function drawStrokeOnPointerZone(
|
||||
page: Page,
|
||||
dialog: ReturnType<typeof page.locator>
|
||||
) {
|
||||
const pointerZone = dialog.locator(
|
||||
'.maskEditor-ui-container [class*="w-[calc"]'
|
||||
)
|
||||
await expect(pointerZone).toBeVisible()
|
||||
|
||||
const box = await pointerZone.boundingBox()
|
||||
if (!box) throw new Error('Pointer zone bounding box not found')
|
||||
|
||||
const startX = box.x + box.width * 0.3
|
||||
const startY = box.y + box.height * 0.5
|
||||
const endX = box.x + box.width * 0.7
|
||||
const endY = box.y + box.height * 0.5
|
||||
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(endX, endY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
|
||||
return { startX, startY, endX, endY, box }
|
||||
}
|
||||
|
||||
async function drawStrokeAndExpectPixels(
|
||||
comfyPage: ComfyPage,
|
||||
dialog: ReturnType<typeof comfyPage.page.locator>
|
||||
) {
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test(
|
||||
'opens mask editor from image preview button',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
async ({ comfyPage, maskEditor }) => {
|
||||
const { imagePreview } = await maskEditor.loadImageOnNode()
|
||||
|
||||
// Hover over the image panel to reveal action buttons
|
||||
await imagePreview.getByRole('region').hover()
|
||||
@@ -139,8 +35,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
test(
|
||||
'opens mask editor from context menu',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const { nodeId } = await loadImageOnNode(comfyPage)
|
||||
async ({ comfyPage, maskEditor }) => {
|
||||
const { nodeId } = await maskEditor.loadImageOnNode()
|
||||
|
||||
const nodeHeader = comfyPage.vueNodes
|
||||
.getNodeLocator(nodeId)
|
||||
@@ -166,63 +62,61 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
)
|
||||
|
||||
test('draws a brush stroke on the mask canvas', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('draws a brush stroke on the mask canvas', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
|
||||
const dataBefore = await maskEditor.getCanvasPixelData(2)
|
||||
expect(dataBefore).not.toBeNull()
|
||||
expect(dataBefore!.nonTransparentPixels).toBe(0)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
})
|
||||
|
||||
test('undo reverts a brush stroke', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('undo reverts a brush stroke', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
const undoButton = dialog.locator('button[title="Undo"]')
|
||||
await expect(undoButton).toBeVisible()
|
||||
await undoButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('redo restores an undone stroke', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('redo restores an undone stroke', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
const undoButton = dialog.locator('button[title="Undo"]')
|
||||
await undoButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
||||
|
||||
const redoButton = dialog.locator('button[title="Redo"]')
|
||||
await expect(redoButton).toBeVisible()
|
||||
await redoButton.click()
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.toBeGreaterThan(0)
|
||||
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('clear button removes all mask content', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('clear button removes all mask content', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
const clearButton = dialog.getByRole('button', { name: 'Clear' })
|
||||
await expect(clearButton).toBeVisible()
|
||||
await clearButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
||||
})
|
||||
|
||||
test('cancel closes the dialog without saving', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('cancel closes the dialog without saving', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
|
||||
await cancelButton.click()
|
||||
@@ -230,10 +124,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('invert button inverts the mask', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('invert button inverts the mask', async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
|
||||
const dataBefore = await maskEditor.getCanvasPixelData(2)
|
||||
expect(dataBefore).not.toBeNull()
|
||||
const pixelsBefore = dataBefore!.nonTransparentPixels
|
||||
|
||||
@@ -242,26 +136,29 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await invertButton.click()
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.poll(() => maskEditor.pollMaskPixelCount())
|
||||
.toBeGreaterThan(pixelsBefore)
|
||||
})
|
||||
|
||||
test('keyboard shortcut Ctrl+Z triggers undo', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('keyboard shortcut Ctrl+Z triggers undo', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
|
||||
await comfyPage.page.keyboard.press(modifier)
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
||||
})
|
||||
|
||||
test(
|
||||
'tool panel shows all five tools',
|
||||
{ tag: ['@smoke'] },
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const toolPanel = dialog.locator('.maskEditor-ui-container')
|
||||
await expect(toolPanel).toBeVisible()
|
||||
@@ -279,9 +176,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
)
|
||||
|
||||
test('switching tools updates the selected indicator', async ({
|
||||
comfyPage
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
@@ -300,9 +197,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
|
||||
test('brush settings panel is visible with thickness controls', async ({
|
||||
comfyPage
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// The side panel should show brush settings by default
|
||||
const thicknessLabel = dialog.getByText('Thickness')
|
||||
@@ -315,8 +212,11 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await expect(hardnessLabel).toBeVisible()
|
||||
})
|
||||
|
||||
test('save uploads all layers and closes dialog', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('save uploads all layers and closes dialog', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
let maskUploadCount = 0
|
||||
let imageUploadCount = 0
|
||||
@@ -359,8 +259,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// Fail all upload routes
|
||||
await comfyPage.page.route('**/upload/mask', (route) =>
|
||||
@@ -380,23 +280,23 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
test(
|
||||
'eraser tool removes mask content',
|
||||
{ tag: ['@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
async ({ maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// Draw a stroke with the mask pen (default tool)
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page)
|
||||
const pixelsAfterDraw = await maskEditor.getCanvasPixelData(2)
|
||||
|
||||
// Switch to eraser tool (3rd tool, index 2)
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await toolEntries.nth(2).click()
|
||||
|
||||
// Draw over the same area with the eraser
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
await maskEditor.drawStrokeOnPointerZone(dialog)
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.poll(() => maskEditor.pollMaskPixelCount())
|
||||
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
|
||||
}
|
||||
)
|
||||
|
||||
100
browser_tests/tests/maskEditorBrushLayers.spec.ts
Normal file
100
browser_tests/tests/maskEditorBrushLayers.spec.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
|
||||
const RGB_PAINT_TOOL_INDEX = 1 // RGB / color paint tool
|
||||
const ERASER_TOOL_INDEX = 2 // Eraser tool
|
||||
|
||||
test.describe(
|
||||
'Mask Editor brush adjustment and layer management',
|
||||
{ tag: '@vue-nodes' },
|
||||
() => {
|
||||
test.describe('Brush settings interaction', () => {
|
||||
test('Adjusting brush thickness slider changes stroke output', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
const thicknessInput = maskEditor.brushInput(dialog, 'thickness')
|
||||
|
||||
// Thin brush
|
||||
await thicknessInput.fill('2')
|
||||
await expect(thicknessInput).toHaveValue('2')
|
||||
|
||||
await maskEditor.drawStrokeOnPointerZone(dialog)
|
||||
await expect
|
||||
.poll(() => maskEditor.pollMaskPixelCount())
|
||||
.toBeGreaterThan(0)
|
||||
const thinPixels = await maskEditor.pollMaskPixelCount()
|
||||
|
||||
await comfyPage.page.keyboard.press('Control+z')
|
||||
await expect.poll(() => maskEditor.pollMaskPixelCount()).toBe(0)
|
||||
|
||||
// Thick brush
|
||||
await thicknessInput.fill('200')
|
||||
await expect(thicknessInput).toHaveValue('200')
|
||||
|
||||
await maskEditor.drawStrokeOnPointerZone(dialog)
|
||||
await expect
|
||||
.poll(() => maskEditor.pollMaskPixelCount())
|
||||
.toBeGreaterThan(thinPixels)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Layer management', () => {
|
||||
test('Drawing on different tools produces independent mask data', async ({
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await maskEditor.drawStrokeOnPointerZone(dialog)
|
||||
await expect
|
||||
.poll(() => maskEditor.pollMaskPixelCount())
|
||||
.toBeGreaterThan(0)
|
||||
const maskSnapshotAfterPen = await maskEditor.getCanvasSnapshot(2)
|
||||
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
await toolEntries.nth(RGB_PAINT_TOOL_INDEX).click()
|
||||
await expect(toolEntries.nth(RGB_PAINT_TOOL_INDEX)).toHaveClass(
|
||||
/Selected/
|
||||
)
|
||||
|
||||
await maskEditor.drawStrokeOnPointerZone(dialog)
|
||||
await expect
|
||||
.poll(() => maskEditor.pollRgbPixelCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await expect
|
||||
.poll(() => maskEditor.getCanvasSnapshot(2))
|
||||
.toBe(maskSnapshotAfterPen)
|
||||
})
|
||||
|
||||
test("Switching between tools preserves previous tool's mask data", async ({
|
||||
maskEditor
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
await maskEditor.drawStrokeOnPointerZone(dialog)
|
||||
await expect
|
||||
.poll(() => maskEditor.pollMaskPixelCount())
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
const maskSnapshot = await maskEditor.getCanvasSnapshot(2)
|
||||
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
|
||||
await toolEntries.nth(ERASER_TOOL_INDEX).click()
|
||||
await expect(toolEntries.nth(ERASER_TOOL_INDEX)).toHaveClass(/Selected/)
|
||||
|
||||
await toolEntries.nth(0).click()
|
||||
await expect(toolEntries.nth(0)).toHaveClass(/Selected/)
|
||||
|
||||
await expect
|
||||
.poll(() => maskEditor.getCanvasSnapshot(2))
|
||||
.toBe(maskSnapshot)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
168
browser_tests/tests/nodeReplacement.spec.ts
Normal file
168
browser_tests/tests/nodeReplacement.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
mockNodeReplacements,
|
||||
mockNodeReplacementsSingle
|
||||
} from '@e2e/fixtures/data/nodeReplacements'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
|
||||
import {
|
||||
getSwapNodesGroup,
|
||||
setupNodeReplacement
|
||||
} from '@e2e/fixtures/helpers/NodeReplacementHelper'
|
||||
|
||||
const renderModes = [
|
||||
{ name: 'vue nodes', vueNodesEnabled: true },
|
||||
{ name: 'litegraph', vueNodesEnabled: false }
|
||||
] as const
|
||||
|
||||
test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
|
||||
for (const mode of renderModes) {
|
||||
test.describe(`(${mode.name})`, () => {
|
||||
test.describe('Single replacement', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
mode.vueNodesEnabled
|
||||
)
|
||||
await setupNodeReplacement(comfyPage, mockNodeReplacementsSingle)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/node_replacement_simple'
|
||||
)
|
||||
})
|
||||
|
||||
test('Swap Nodes group appears in errors tab for replaceable nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
await expect(swapGroup).toBeVisible()
|
||||
await expect(swapGroup).toContainText('E2E_OldSampler')
|
||||
await expect(
|
||||
swapGroup.getByRole('button', { name: 'Replace All', exact: true })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Replace Node replaces a single group in-place', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
await swapGroup.getByRole('button', { name: /replace node/i }).click()
|
||||
await expect(swapGroup).toBeHidden()
|
||||
|
||||
const workflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
expect(
|
||||
workflow.nodes,
|
||||
'Node count should be unchanged after in-place replacement'
|
||||
).toHaveLength(2)
|
||||
|
||||
const nodeTypes = workflow.nodes.map((n) => n.type)
|
||||
expect(nodeTypes).not.toContain('E2E_OldSampler')
|
||||
expect(nodeTypes).toContain('KSampler')
|
||||
|
||||
const ksampler = workflow.nodes.find((n) => n.type === 'KSampler')
|
||||
expect(
|
||||
ksampler?.id,
|
||||
'Replaced node should keep the original id'
|
||||
).toBe(1)
|
||||
|
||||
const linkFromReplacedToDecode = workflow.links?.find(
|
||||
(l) => l[1] === 1 && l[3] === 2
|
||||
)
|
||||
expect(
|
||||
linkFromReplacedToDecode,
|
||||
'Output link from replaced node to VAEDecode should be preserved'
|
||||
).toBeDefined()
|
||||
})
|
||||
|
||||
test('Widget values are preserved after replacement', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await getSwapNodesGroup(comfyPage.page)
|
||||
.getByRole('button', { name: /replace node/i })
|
||||
.click()
|
||||
|
||||
const workflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
const ksampler = workflow.nodes.find((n) => n.type === 'KSampler')
|
||||
|
||||
expect(ksampler?.widgets_values).toBeDefined()
|
||||
const widgetValues = ksampler!.widgets_values as unknown[]
|
||||
expect(widgetValues).toEqual([
|
||||
42,
|
||||
'randomize',
|
||||
20,
|
||||
7,
|
||||
'euler',
|
||||
'normal',
|
||||
1
|
||||
])
|
||||
})
|
||||
|
||||
test('Success toast is shown after replacement', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await getSwapNodesGroup(comfyPage.page)
|
||||
.getByRole('button', { name: /replace node/i })
|
||||
.click()
|
||||
|
||||
await expect(comfyPage.visibleToasts.first()).toContainText(
|
||||
/replaced|swapped/i
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Multi-type replacement', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.VueNodes.Enabled',
|
||||
mode.vueNodesEnabled
|
||||
)
|
||||
await setupNodeReplacement(comfyPage, mockNodeReplacements)
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/node_replacement_multi'
|
||||
)
|
||||
})
|
||||
|
||||
test('Replace All replaces all groups across multiple types', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const swapGroup = getSwapNodesGroup(comfyPage.page)
|
||||
await expect(swapGroup).toBeVisible()
|
||||
await expect(swapGroup).toContainText('E2E_OldSampler')
|
||||
await expect(swapGroup).toContainText('E2E_OldUpscaler')
|
||||
|
||||
await swapGroup
|
||||
.getByRole('button', { name: 'Replace All', exact: true })
|
||||
.click()
|
||||
await expect(swapGroup).toBeHidden()
|
||||
|
||||
const workflow = await comfyPage.workflow.getExportedWorkflow()
|
||||
const nodeTypes = workflow.nodes.map((n) => n.type)
|
||||
expect(nodeTypes).not.toContain('E2E_OldSampler')
|
||||
expect(nodeTypes).not.toContain('E2E_OldUpscaler')
|
||||
expect(nodeTypes).toContain('KSampler')
|
||||
expect(nodeTypes).toContain('ImageScaleBy')
|
||||
})
|
||||
|
||||
test('Output connections are preserved across replacement with output mapping', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await getSwapNodesGroup(comfyPage.page)
|
||||
.getByRole('button', { name: 'Replace All', exact: true })
|
||||
.click()
|
||||
|
||||
const replacedNodeOutputLinkCount = await comfyPage.page.evaluate(
|
||||
() =>
|
||||
window.app!.graph!.getNodeById(2)?.outputs[0]?.links?.length ?? 0
|
||||
)
|
||||
expect(
|
||||
replacedNodeOutputLinkCount,
|
||||
'Replaced upscaler should still drive its downstream consumer'
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import {
|
||||
countAssetRequestsByTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
const WORKFLOW = 'missing/nested_subgraph_installed_model'
|
||||
const OUTER_SUBGRAPH_NODE_ID = '205'
|
||||
const LOTUS_MODEL_NAME = 'lotus-depth-d-v1-1.safetensors'
|
||||
|
||||
const LOTUS_DIFFUSION_MODEL: Asset = {
|
||||
id: 'test-lotus-depth-d-v1-1',
|
||||
name: LOTUS_MODEL_NAME,
|
||||
asset_hash:
|
||||
'blake3:0000000000000000000000000000000000000000000000000000000000000203',
|
||||
size: 1_024,
|
||||
mime_type: 'application/octet-stream',
|
||||
tags: ['models', 'diffusion_models'],
|
||||
created_at: '2026-05-05T00:00:00Z',
|
||||
updated_at: '2026-05-05T00:00:00Z',
|
||||
last_access_time: '2026-05-05T00:00:00Z',
|
||||
user_metadata: {
|
||||
filename: LOTUS_MODEL_NAME
|
||||
}
|
||||
}
|
||||
|
||||
const test = createCloudAssetsFixture([LOTUS_DIFFUSION_MODEL])
|
||||
|
||||
test.describe(
|
||||
'Errors tab - Cloud missing models',
|
||||
{ tag: ['@cloud', '@vue-nodes'] },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('keeps installed models resolved after returning from a nested subgraph', async ({
|
||||
cloudAssetRequests,
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
const errorsTab = panel.root.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => countAssetRequestsByTag(cloudAssetRequests, 'diffusion_models'),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
await expect(errorsTab).toBeHidden()
|
||||
await panel.close()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph(OUTER_SUBGRAPH_NODE_ID)
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
const requestCountBeforeRootReturn = countAssetRequestsByTag(
|
||||
cloudAssetRequests,
|
||||
'diffusion_models'
|
||||
)
|
||||
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
countAssetRequestsByTag(cloudAssetRequests, 'diffusion_models') >
|
||||
requestCountBeforeRootReturn,
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await expect(errorsTab).toBeHidden()
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,13 +1,19 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import type { JobEntry } from '@comfyorg/ingest-types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import { jobsApiMockFixture } from '@e2e/fixtures/jobsApiMockFixture'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobRecords
|
||||
} from '@e2e/fixtures/utils/jobFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, jobsApiMockFixture)
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
const MOCK_JOBS: RawJobListItem[] = [
|
||||
const MOCK_JOBS: JobEntry[] = [
|
||||
createMockJob({
|
||||
id: 'job-completed-1',
|
||||
status: 'completed',
|
||||
@@ -31,20 +37,25 @@ const MOCK_JOBS: RawJobListItem[] = [
|
||||
execution_start_time: now - 30_000,
|
||||
execution_end_time: now - 28_000,
|
||||
outputs_count: 0
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-failed-bottom',
|
||||
status: 'failed',
|
||||
create_time: now - 180_000,
|
||||
execution_start_time: now - 180_000,
|
||||
execution_end_time: now - 178_000,
|
||||
outputs_count: 0
|
||||
})
|
||||
]
|
||||
|
||||
test.describe('Queue overlay', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(MOCK_JOBS)
|
||||
test.beforeEach(async ({ comfyPage, jobsApi }) => {
|
||||
await jobsApi.mockJobs(createMockJobRecords(MOCK_JOBS))
|
||||
await comfyPage.settings.setSetting('Comfy.Minimap.Visible', false)
|
||||
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', false)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Toggle button opens expanded queue overlay', async ({ comfyPage }) => {
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
@@ -106,4 +117,64 @@ test.describe('Queue overlay', () => {
|
||||
|
||||
await expect(comfyPage.page.locator('[data-job-id]').first()).toBeHidden()
|
||||
})
|
||||
|
||||
test('Job details popover stays inside the viewport for bottom rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
|
||||
|
||||
const toggle = comfyPage.page.getByTestId(TestIds.queue.overlayToggle)
|
||||
await toggle.click()
|
||||
|
||||
const bottomJob = comfyPage.page.locator(
|
||||
'[data-job-id="job-failed-bottom"]'
|
||||
)
|
||||
await expect(bottomJob).toBeVisible()
|
||||
await bottomJob.scrollIntoViewIfNeeded()
|
||||
await expect(bottomJob).toBeVisible()
|
||||
|
||||
const viewportSize = comfyPage.page.viewportSize()
|
||||
if (!viewportSize) throw new Error('Viewport must be available')
|
||||
|
||||
const rowBox = await bottomJob.boundingBox()
|
||||
if (!rowBox) throw new Error('Bottom job row should be measurable')
|
||||
expect(
|
||||
rowBox.y + rowBox.height,
|
||||
'Test row should be low enough to exercise bottom-edge collision handling'
|
||||
).toBeGreaterThan(viewportSize.height * 0.55)
|
||||
await expect
|
||||
.poll(async () =>
|
||||
bottomJob.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
const hitTarget = document.elementFromPoint(
|
||||
rect.x + rect.width / 2,
|
||||
rect.y + rect.height / 2
|
||||
)
|
||||
return hitTarget ? element.contains(hitTarget) : false
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
await comfyPage.page.mouse.move(0, 0)
|
||||
await comfyPage.page.mouse.move(
|
||||
rowBox.x + rowBox.width / 2,
|
||||
rowBox.y + rowBox.height / 2,
|
||||
{ steps: 5 }
|
||||
)
|
||||
|
||||
const popover = comfyPage.page.getByTestId(TestIds.queue.jobDetailsPopover)
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const popoverBox = await popover.boundingBox()
|
||||
if (!popoverBox) return false
|
||||
|
||||
return (
|
||||
popoverBox.y >= 0 &&
|
||||
popoverBox.y + popoverBox.height <= viewportSize.height
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const TOTAL_MOCK_JOBS = 20
|
||||
const MAX_HISTORY_ITEMS_SETTING = 'Comfy.Queue.MaxHistoryItems'
|
||||
const overflowJobsListRoutePattern = '**/api/jobs?*'
|
||||
|
||||
function isHistoryJobsRequest(url: string): boolean {
|
||||
@@ -59,7 +60,7 @@ test.describe('Queue settings', { tag: '@canvas' }, () => {
|
||||
}) => {
|
||||
const TARGET_LIMIT = 6
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Queue.MaxHistoryItems',
|
||||
MAX_HISTORY_ITEMS_SETTING,
|
||||
TARGET_LIMIT
|
||||
)
|
||||
|
||||
@@ -106,7 +107,7 @@ test.describe('Queue settings', { tag: '@canvas' }, () => {
|
||||
|
||||
const VISIBLE_LIMIT = 6
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Queue.MaxHistoryItems',
|
||||
MAX_HISTORY_ITEMS_SETTING,
|
||||
VISIBLE_LIMIT
|
||||
)
|
||||
const exec = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
|
||||
49
browser_tests/tests/workflowDeleteSettings.spec.ts
Normal file
49
browser_tests/tests/workflowDeleteSettings.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const WORKFLOW_NAME = 'test-confirm-delete'
|
||||
|
||||
async function startDeletingFromSidebar(comfyPage: ComfyPage) {
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(WORKFLOW_NAME).click({ button: 'right' })
|
||||
await comfyPage.contextMenu.clickMenuItem('Delete')
|
||||
}
|
||||
|
||||
test.describe('Comfy.Workflow.ConfirmDelete', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.saveWorkflowAs(WORKFLOW_NAME)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('on (default): right-click → Delete prompts the confirm dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', true)
|
||||
|
||||
await startDeletingFromSidebar(comfyPage)
|
||||
|
||||
await expect(comfyPage.confirmDialog.root).toBeVisible()
|
||||
await expect(comfyPage.confirmDialog.delete).toBeVisible()
|
||||
})
|
||||
|
||||
test('off: right-click → Delete bypasses the confirm dialog', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Workflow.ConfirmDelete', false)
|
||||
|
||||
await startDeletingFromSidebar(comfyPage)
|
||||
|
||||
const { workflowsTab } = comfyPage.menu
|
||||
await expect(comfyPage.confirmDialog.root).toBeHidden()
|
||||
await expect
|
||||
.poll(() => workflowsTab.getTopLevelSavedWorkflowNames())
|
||||
.not.toContain(WORKFLOW_NAME)
|
||||
})
|
||||
})
|
||||
@@ -147,7 +147,7 @@ it('should subscribe to logs API', () => {
|
||||
})
|
||||
```
|
||||
|
||||
## Mocking Lodash Functions
|
||||
## Mocking Utility Functions
|
||||
|
||||
Mocking utility functions like debounce:
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ const settings = {
|
||||
alwaysTryTypes: true,
|
||||
project: [
|
||||
'./tsconfig.json',
|
||||
'./browser_tests/tsconfig.json',
|
||||
'./apps/*/tsconfig.json',
|
||||
'./packages/*/tsconfig.json'
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.44.15",
|
||||
"version": "1.44.17",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -29,6 +29,17 @@ export type {
|
||||
BillingStatus,
|
||||
BillingStatusResponse,
|
||||
BindingErrorResponse,
|
||||
BulkRevokeApiKeysResponse,
|
||||
BulkRevokeWorkspaceMemberApiKeysData,
|
||||
BulkRevokeWorkspaceMemberApiKeysError,
|
||||
BulkRevokeWorkspaceMemberApiKeysErrors,
|
||||
BulkRevokeWorkspaceMemberApiKeysResponse,
|
||||
BulkRevokeWorkspaceMemberApiKeysResponses,
|
||||
CancelJobData,
|
||||
CancelJobError,
|
||||
CancelJobErrors,
|
||||
CancelJobResponse,
|
||||
CancelJobResponses,
|
||||
CancelSubscriptionData,
|
||||
CancelSubscriptionError,
|
||||
CancelSubscriptionErrors,
|
||||
@@ -307,6 +318,28 @@ export type {
|
||||
GetJwksData,
|
||||
GetJwksResponse,
|
||||
GetJwksResponses,
|
||||
GetLegacyAssetContentData,
|
||||
GetLegacyAssetContentErrors,
|
||||
GetLegacyHistoryByIdData,
|
||||
GetLegacyHistoryByIdErrors,
|
||||
GetLegacyHistoryData,
|
||||
GetLegacyHistoryErrors,
|
||||
GetLegacyJobByIdData,
|
||||
GetLegacyJobByIdErrors,
|
||||
GetLegacyJobOutputsData,
|
||||
GetLegacyJobOutputsErrors,
|
||||
GetLegacyModelsByFolderData,
|
||||
GetLegacyModelsByFolderErrors,
|
||||
GetLegacyModelsData,
|
||||
GetLegacyModelsErrors,
|
||||
GetLegacyObjectInfoByNodeClassData,
|
||||
GetLegacyObjectInfoByNodeClassErrors,
|
||||
GetLegacyPromptByIdData,
|
||||
GetLegacyPromptByIdErrors,
|
||||
GetLegacyUserdataV2Data,
|
||||
GetLegacyUserdataV2Errors,
|
||||
GetLegacyViewMetadataData,
|
||||
GetLegacyViewMetadataErrors,
|
||||
GetLogsData,
|
||||
GetLogsError,
|
||||
GetLogsErrors,
|
||||
@@ -505,6 +538,7 @@ export type {
|
||||
InterruptJobError,
|
||||
InterruptJobErrors,
|
||||
InterruptJobResponses,
|
||||
JobCancelResponse,
|
||||
JobDetailResponse,
|
||||
JobEntry,
|
||||
JobsListResponse,
|
||||
@@ -719,6 +753,13 @@ export type {
|
||||
SubscribeResponses,
|
||||
SubscriptionDuration,
|
||||
SubscriptionTier,
|
||||
SyncApiKeyData,
|
||||
SyncApiKeyError,
|
||||
SyncApiKeyErrors,
|
||||
SyncApiKeyRequest,
|
||||
SyncApiKeyResponse,
|
||||
SyncApiKeyResponse2,
|
||||
SyncApiKeyResponses,
|
||||
SystemStatsResponse,
|
||||
TagInfo,
|
||||
TagsModificationResponse,
|
||||
|
||||
785
packages/ingest-types/src/types.gen.ts
generated
785
packages/ingest-types/src/types.gen.ts
generated
File diff suppressed because it is too large
Load Diff
497
packages/ingest-types/src/zod.gen.ts
generated
497
packages/ingest-types/src/zod.gen.ts
generated
File diff suppressed because it is too large
Load Diff
188
packages/registry-types/src/comfyRegistryTypes.ts
generated
188
packages/registry-types/src/comfyRegistryTypes.ts
generated
@@ -2524,6 +2524,46 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/proxy/luma_2/generations": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Create a Luma Agents generation
|
||||
* @description Submit an image generation or edit job. Returns immediately with an opaque job ID to poll via GET /proxy/luma_2/generations/{id}.
|
||||
*/
|
||||
post: operations["lumaAgentsCreateGeneration"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/proxy/luma_2/generations/{generation_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get a Luma Agents generation
|
||||
* @description Poll for generation status and output. On completion, the response includes presigned URLs to download the generated images.
|
||||
*/
|
||||
get: operations["lumaAgentsGetGeneration"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/proxy/pixverse/video/text/generate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -10381,6 +10421,88 @@ export interface components {
|
||||
/** @description The request of the generation */
|
||||
request?: components["schemas"]["LumaGenerationRequest"] | components["schemas"]["LumaImageGenerationRequest"] | components["schemas"]["LumaUpscaleVideoGenerationRequest"] | components["schemas"]["LumaAudioGenerationRequest"];
|
||||
};
|
||||
/**
|
||||
* @description Output aspect ratio
|
||||
* @enum {string}
|
||||
*/
|
||||
LumaAgentsAspectRatio: "3:1" | "2:1" | "16:9" | "3:2" | "1:1" | "2:3" | "9:16" | "1:2" | "1:3";
|
||||
/**
|
||||
* @description Style preset
|
||||
* @enum {string}
|
||||
*/
|
||||
LumaAgentsStyle: "auto" | "manga";
|
||||
/**
|
||||
* @description Output image format
|
||||
* @enum {string}
|
||||
*/
|
||||
LumaAgentsOutputFormat: "png" | "jpeg";
|
||||
/**
|
||||
* @description The kind of generation to perform
|
||||
* @enum {string}
|
||||
*/
|
||||
LumaAgentsGenerationType: "image" | "image_edit";
|
||||
/**
|
||||
* @description Current state of the generation
|
||||
* @enum {string}
|
||||
*/
|
||||
LumaAgentsState: "queued" | "processing" | "completed" | "failed";
|
||||
/**
|
||||
* @description Machine-readable failure code for programmatic handling
|
||||
* @enum {string}
|
||||
*/
|
||||
LumaAgentsFailureCode: "content_moderated" | "generation_failed" | "budget_exhausted" | "output_not_found";
|
||||
/** @description Reference image for style/content guidance or guided generation */
|
||||
LumaAgentsImageRef: {
|
||||
/** @description Base64-encoded image data */
|
||||
data?: string;
|
||||
/** @description MIME type (e.g. image/jpeg). Required with data. */
|
||||
media_type?: string;
|
||||
/** @description Publicly accessible image URL */
|
||||
url?: string;
|
||||
};
|
||||
/** @description The Luma Agents generation request object */
|
||||
LumaAgentsGenerationRequest: {
|
||||
/** @description Text prompt */
|
||||
prompt: string;
|
||||
aspect_ratio?: components["schemas"]["LumaAgentsAspectRatio"];
|
||||
/** @description Reference images for style/content guidance. Up to 9 for type 'image', up to 8 for type 'image_edit'. */
|
||||
image_ref?: components["schemas"]["LumaAgentsImageRef"][];
|
||||
/** @description Model to use */
|
||||
model?: string;
|
||||
output_format?: components["schemas"]["LumaAgentsOutputFormat"];
|
||||
source?: components["schemas"]["LumaAgentsImageRef"];
|
||||
style?: components["schemas"]["LumaAgentsStyle"];
|
||||
type?: components["schemas"]["LumaAgentsGenerationType"];
|
||||
/** @description Enable web search grounding */
|
||||
web_search?: boolean;
|
||||
};
|
||||
/** @description A generated output entry */
|
||||
LumaAgentsGenerationOutput: {
|
||||
/** @description Media type (e.g. image) */
|
||||
type?: string;
|
||||
/** @description Presigned URL (1hr expiry) */
|
||||
url?: string;
|
||||
};
|
||||
/** @description Generation status and output */
|
||||
LumaAgentsGeneration: {
|
||||
/** @description Generation identifier */
|
||||
id?: string;
|
||||
/** @description Creation timestamp */
|
||||
created_at?: string;
|
||||
/** @description Model used */
|
||||
model?: string;
|
||||
state?: components["schemas"]["LumaAgentsState"];
|
||||
type?: components["schemas"]["LumaAgentsGenerationType"];
|
||||
failure_code?: components["schemas"]["LumaAgentsFailureCode"];
|
||||
/** @description Human-readable failure description */
|
||||
failure_reason?: string;
|
||||
output?: components["schemas"]["LumaAgentsGenerationOutput"][];
|
||||
};
|
||||
/** @description The error object */
|
||||
LumaAgentsError: {
|
||||
/** @description The error message */
|
||||
detail?: string;
|
||||
};
|
||||
PixverseTextVideoRequest: {
|
||||
/** @enum {string} */
|
||||
aspect_ratio: "16:9" | "4:3" | "1:1" | "3:4" | "9:16";
|
||||
@@ -26183,6 +26305,72 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
lumaAgentsCreateGeneration: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description The generation request object */
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["LumaAgentsGenerationRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Generation accepted */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["LumaAgentsGeneration"];
|
||||
};
|
||||
};
|
||||
/** @description Error */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["LumaAgentsError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
lumaAgentsGetGeneration: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description The ID of the generation */
|
||||
generation_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Generation found */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["LumaAgentsGeneration"];
|
||||
};
|
||||
};
|
||||
/** @description Error */
|
||||
default: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["LumaAgentsError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
PixverseGenerateTextVideo: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
104
src/components/common/CustomizationDialog.test.ts
Normal file
104
src/components/common/CustomizationDialog.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CustomizationDialog from './CustomizationDialog.vue'
|
||||
|
||||
const DEFAULT_ICON = 'pi-bookmark-fill'
|
||||
const DEFAULT_COLOR = '#a1a1aa'
|
||||
|
||||
vi.mock('@/stores/nodeBookmarkStore', () => ({
|
||||
useNodeBookmarkStore: () => ({
|
||||
defaultBookmarkIcon: DEFAULT_ICON,
|
||||
defaultBookmarkColor: DEFAULT_COLOR,
|
||||
bookmarksCustomization: {}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('primevue/dialog', () => ({
|
||||
default: {
|
||||
name: 'Dialog',
|
||||
template: '<div v-if="visible"><slot /><slot name="footer" /></div>',
|
||||
props: ['visible']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/selectbutton', () => ({
|
||||
default: {
|
||||
name: 'SelectButton',
|
||||
template: '<div />',
|
||||
props: ['modelValue', 'options']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/divider', () => ({
|
||||
default: { name: 'Divider', template: '<hr />' }
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/ColorCustomizationSelector.vue', () => ({
|
||||
default: {
|
||||
name: 'ColorCustomizationSelector',
|
||||
template: '<div />',
|
||||
props: ['modelValue', 'colorOptions']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui/button/Button.vue', () => ({
|
||||
default: {
|
||||
name: 'Button',
|
||||
template: `<button @click="$emit('click')"><slot /></button>`,
|
||||
emits: ['click']
|
||||
}
|
||||
}))
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
|
||||
|
||||
function renderDialog(extraProps: Record<string, unknown> = {}) {
|
||||
const onConfirm = vi.fn()
|
||||
render(CustomizationDialog, {
|
||||
global: { plugins: [i18n] },
|
||||
props: { modelValue: true, onConfirm, ...extraProps }
|
||||
})
|
||||
return { onConfirm }
|
||||
}
|
||||
|
||||
describe('CustomizationDialog', () => {
|
||||
describe('confirmCustomization', () => {
|
||||
it('emits confirm with default icon and color when no initial values provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onConfirm } = renderDialog()
|
||||
|
||||
await user.click(screen.getByText('g.confirm'))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, DEFAULT_COLOR)
|
||||
})
|
||||
|
||||
it('emits confirm with matching initialIcon when provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onConfirm } = renderDialog({ initialIcon: 'pi-star' })
|
||||
|
||||
await user.click(screen.getByText('g.confirm'))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith('pi-star', DEFAULT_COLOR)
|
||||
})
|
||||
|
||||
it('falls back to default icon when initialIcon does not match any option', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onConfirm } = renderDialog({ initialIcon: 'pi-nonexistent' })
|
||||
|
||||
await user.click(screen.getByText('g.confirm'))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, DEFAULT_COLOR)
|
||||
})
|
||||
|
||||
it('emits confirm with initialColor when provided', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onConfirm } = renderDialog({ initialColor: '#007bff' })
|
||||
|
||||
await user.click(screen.getByText('g.confirm'))
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, '#007bff')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -94,17 +94,15 @@ const defaultIcon = iconOptions.find(
|
||||
(option) => option.value === nodeBookmarkStore.defaultBookmarkIcon
|
||||
)
|
||||
|
||||
// @ts-expect-error fixme ts strict error
|
||||
const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
|
||||
const selectedIcon = ref(defaultIcon ?? iconOptions[0])
|
||||
const finalColor = ref(
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
)
|
||||
|
||||
const resetCustomization = () => {
|
||||
// @ts-expect-error fixme ts strict error
|
||||
selectedIcon.value =
|
||||
iconOptions.find((option) => option.value === props.initialIcon) ||
|
||||
defaultIcon
|
||||
iconOptions.find((option) => option.value === props.initialIcon) ??
|
||||
iconOptions[0]
|
||||
finalColor.value =
|
||||
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<span class="flex flex-row gap-0.5">
|
||||
<template v-for="(sequence, index) in keySequences" :key="index">
|
||||
<Tag
|
||||
class="min-w-6 justify-center gap-1 bg-interface-menu-keybind-surface-default text-center font-normal text-base-foreground uppercase"
|
||||
class="min-w-6 justify-center gap-1 bg-interface-menu-keybind-surface-default text-center font-normal text-base-foreground capitalize"
|
||||
:severity="isModified ? 'info' : 'secondary'"
|
||||
>
|
||||
{{ sequence }}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<template v-if="showUI" #workflow-tabs>
|
||||
<div
|
||||
v-if="workflowTabsPosition === 'Topbar'"
|
||||
data-testid="topbar-workflow-tabs"
|
||||
class="workflow-tabs-container pointer-events-auto relative h-(--workflow-tabs-height) w-full"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
</span>
|
||||
<input
|
||||
v-model.number="brushSize"
|
||||
data-testid="brush-thickness-input"
|
||||
type="number"
|
||||
class="border-p-form-field-border-color text-input-text w-16 rounded-md border bg-comfy-menu-bg px-2 py-1 text-center text-sm"
|
||||
:min="1"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import type * as RekaUi from 'reka-ui'
|
||||
|
||||
import './testUtils/mockTanstackVirtualizer'
|
||||
|
||||
@@ -27,6 +28,85 @@ vi.mock('@/components/queue/job/JobDetailsPopover.vue', () => ({
|
||||
default: hoisted.jobDetailsPopoverStub
|
||||
}))
|
||||
|
||||
vi.mock('reka-ui', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof RekaUi>()
|
||||
const { computed, defineComponent, h, inject, provide } = await import('vue')
|
||||
const popoverOpenKey = Symbol('popoverOpen')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
PopoverContent: defineComponent({
|
||||
name: 'PopoverContent',
|
||||
props: {
|
||||
align: { type: String, default: undefined },
|
||||
avoidCollisions: { type: Boolean, default: undefined },
|
||||
collisionPadding: { type: Number, default: undefined },
|
||||
hideWhenDetached: { type: Boolean, default: undefined },
|
||||
positionStrategy: { type: String, default: undefined },
|
||||
reference: { type: null, default: undefined },
|
||||
side: { type: String, default: undefined },
|
||||
sideFlip: { type: Boolean, default: undefined },
|
||||
sideOffset: { type: Number, default: undefined },
|
||||
sticky: { type: String, default: undefined }
|
||||
},
|
||||
emits: ['mouseenter', 'mouseleave'],
|
||||
setup(props, { attrs, emit, slots }) {
|
||||
const isOpen = inject(
|
||||
popoverOpenKey,
|
||||
computed(() => false)
|
||||
)
|
||||
return () =>
|
||||
isOpen.value
|
||||
? h(
|
||||
'div',
|
||||
{
|
||||
class: attrs.class,
|
||||
'data-align': props.align,
|
||||
'data-avoid-collisions': props.avoidCollisions,
|
||||
'data-collision-padding': props.collisionPadding,
|
||||
'data-hide-when-detached': props.hideWhenDetached,
|
||||
'data-position-strategy': props.positionStrategy,
|
||||
'data-reference-bound': props.reference ? 'true' : 'false',
|
||||
'data-side': props.side,
|
||||
'data-side-flip': props.sideFlip,
|
||||
'data-side-offset': props.sideOffset,
|
||||
'data-sticky': props.sticky,
|
||||
onMouseenter: () => emit('mouseenter'),
|
||||
onMouseleave: () => emit('mouseleave')
|
||||
},
|
||||
slots.default?.()
|
||||
)
|
||||
: null
|
||||
}
|
||||
}),
|
||||
PopoverPortal: {
|
||||
name: 'PopoverPortal',
|
||||
template: '<div><slot /></div>'
|
||||
},
|
||||
PopoverRoot: defineComponent({
|
||||
name: 'PopoverRoot',
|
||||
props: {
|
||||
open: { type: Boolean, default: false }
|
||||
},
|
||||
setup(props, { slots }) {
|
||||
provide(
|
||||
popoverOpenKey,
|
||||
computed(() => props.open)
|
||||
)
|
||||
|
||||
return () =>
|
||||
h(
|
||||
'div',
|
||||
{
|
||||
'data-open': props.open
|
||||
},
|
||||
slots.default?.()
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const AssetsListItemStub = defineComponent({
|
||||
name: 'AssetsListItem',
|
||||
props: {
|
||||
@@ -72,6 +152,7 @@ vi.mock('vue-i18n', () => {
|
||||
|
||||
type TestPreviewOutput = {
|
||||
url: string
|
||||
previewUrl: string
|
||||
isImage: boolean
|
||||
isVideo: boolean
|
||||
}
|
||||
@@ -96,6 +177,7 @@ const createPreviewOutput = (
|
||||
const url = `/api/view/${filename}`
|
||||
return {
|
||||
url,
|
||||
previewUrl: mediaType === 'images' ? `${url}?res=512` : url,
|
||||
isImage: mediaType === 'images',
|
||||
isVideo: mediaType === 'video'
|
||||
}
|
||||
@@ -153,30 +235,6 @@ function renderJobAssetsList({
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
function createDomRect({
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
height
|
||||
}: {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
height: number
|
||||
}): DOMRect {
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
height,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
toJSON: () => ''
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
@@ -240,6 +298,18 @@ describe('JobAssetsList', () => {
|
||||
expect(onViewItem).toHaveBeenCalledWith(job)
|
||||
})
|
||||
|
||||
it('uses thumbnail preview URLs for completed image rows', () => {
|
||||
const preview = createPreviewOutput('job-1.png')
|
||||
const job = buildJob({
|
||||
taskRef: createTaskRef(preview)
|
||||
})
|
||||
const { container } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const stubRoot = container.querySelector('.assets-list-item-stub')!
|
||||
expect(stubRoot.getAttribute('data-preview-url')).toBe(preview.previewUrl)
|
||||
expect(stubRoot.getAttribute('data-preview-url')).not.toBe(preview.url)
|
||||
})
|
||||
|
||||
it('emits viewItem on double-click for completed jobs with preview', async () => {
|
||||
const job = buildJob()
|
||||
const onViewItem = vi.fn()
|
||||
@@ -378,54 +448,24 @@ describe('JobAssetsList', () => {
|
||||
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
|
||||
})
|
||||
|
||||
it('positions the popover to the right of rows near the left viewport edge', async () => {
|
||||
it('anchors the popover to the active row through Reka', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
|
||||
vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue(
|
||||
createDomRect({
|
||||
top: 100,
|
||||
left: 40,
|
||||
width: 200,
|
||||
height: 48
|
||||
})
|
||||
)
|
||||
|
||||
await fireEvent.mouseEnter(jobRow)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
const popover = container.querySelector('.job-details-popover')!
|
||||
expect(popover.getAttribute('style')).toContain('left: 248px;')
|
||||
})
|
||||
|
||||
it('positions the popover to the left of rows near the right viewport edge', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = buildJob()
|
||||
const { container } = renderJobAssetsList({ jobs: [job] })
|
||||
|
||||
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
|
||||
|
||||
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
|
||||
vi.spyOn(jobRow, 'getBoundingClientRect').mockReturnValue(
|
||||
createDomRect({
|
||||
top: 100,
|
||||
left: 980,
|
||||
width: 200,
|
||||
height: 48
|
||||
})
|
||||
)
|
||||
|
||||
await fireEvent.mouseEnter(jobRow)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
|
||||
const popover = container.querySelector('.job-details-popover')!
|
||||
expect(popover.getAttribute('style')).toContain('left: 672px;')
|
||||
expect(popover.getAttribute('data-avoid-collisions')).toBe('true')
|
||||
expect(popover.getAttribute('data-hide-when-detached')).toBe('true')
|
||||
expect(popover.getAttribute('data-reference-bound')).toBe('true')
|
||||
expect(popover.getAttribute('data-side')).toBe('right')
|
||||
expect(popover.getAttribute('data-side-flip')).toBe('true')
|
||||
expect(popover.getAttribute('data-position-strategy')).toBe('fixed')
|
||||
})
|
||||
|
||||
it('clears the previous popover when hovering a new row briefly and leaving the list', async () => {
|
||||
@@ -458,7 +498,7 @@ describe('JobAssetsList', () => {
|
||||
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the new popover after the previous row hides while the next row stays hovered', async () => {
|
||||
it('updates the visible popover without closing when hovering another row', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = buildJob({ id: 'job-1' })
|
||||
const secondJob = buildJob({ id: 'job-2', title: 'Job 2' })
|
||||
@@ -479,17 +519,21 @@ describe('JobAssetsList', () => {
|
||||
|
||||
await fireEvent.mouseLeave(firstRow)
|
||||
await fireEvent.mouseEnter(secondRow)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(150)
|
||||
await nextTick()
|
||||
expect(container.querySelector('.job-details-popover-stub')).toBeNull()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
await nextTick()
|
||||
|
||||
const popoverStub = container.querySelector('.job-details-popover-stub')!
|
||||
expect(popoverStub).not.toBeNull()
|
||||
expect(popoverStub.getAttribute('data-job-id')).toBe('job-2')
|
||||
expect(
|
||||
container
|
||||
.querySelector('.job-details-popover-stub')
|
||||
?.getAttribute('data-job-id')
|
||||
).toBe('job-2')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await nextTick()
|
||||
expect(
|
||||
container
|
||||
.querySelector('.job-details-popover-stub')
|
||||
?.getAttribute('data-job-id')
|
||||
).toBe('job-2')
|
||||
})
|
||||
|
||||
it('does not show details if the hovered row disappears before the show delay ends', async () => {
|
||||
|
||||
@@ -90,23 +90,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="activeDetails && popoverPosition"
|
||||
class="job-details-popover fixed z-50"
|
||||
:style="{
|
||||
top: `${popoverPosition.top}px`,
|
||||
left: `${popoverPosition.left}px`
|
||||
}"
|
||||
@mouseenter="onPopoverEnter"
|
||||
@mouseleave="onPopoverLeave"
|
||||
>
|
||||
<JobDetailsPopover
|
||||
:job-id="activeDetails.jobId"
|
||||
:workflow-id="activeDetails.workflowId"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
<JobDetailsHoverPopover
|
||||
:open="isDetailsOpen && !!activeDetails && !!activeRowElement"
|
||||
:job-id="activeDetails?.jobId"
|
||||
:workflow-id="activeDetails?.workflowId"
|
||||
:reference-element="activeRowElement"
|
||||
@content-enter="onPopoverEnter"
|
||||
@content-leave="onPopoverLeave"
|
||||
@update:open="onPopoverOpenChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -114,13 +106,11 @@ import type { VirtualItem } from '@tanstack/vue-virtual'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
|
||||
import JobDetailsHoverPopover from '@/components/queue/job/JobDetailsHoverPopover.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
|
||||
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
|
||||
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { iconForJobState } from '@/utils/queueDisplay'
|
||||
@@ -132,6 +122,8 @@ import type { VirtualJobRow } from './buildVirtualJobRows'
|
||||
const HEADER_ROW_HEIGHT = 20
|
||||
const GROUP_ROW_GAP = 16
|
||||
const JOB_ROW_HEIGHT = 48
|
||||
const DETAILS_SHOW_DELAY_MS = 200
|
||||
const DETAILS_HIDE_DELAY_MS = 150
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
@@ -150,7 +142,11 @@ const { t } = useI18n()
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const hoveredJobId = ref<string | null>(null)
|
||||
const activeRowElement = ref<HTMLElement | null>(null)
|
||||
const popoverPosition = ref<{ top: number; left: number } | null>(null)
|
||||
const activeDetails = ref<{ jobId: string; workflowId?: string } | null>(null)
|
||||
const isDetailsOpen = ref(false)
|
||||
const hideTimer = ref<number | null>(null)
|
||||
const hideTimerJobId = ref<string | null>(null)
|
||||
const showTimer = ref<number | null>(null)
|
||||
const flatRows = computed(() => buildVirtualJobRows(displayedJobGroups))
|
||||
const virtualizer = useVirtualizer({
|
||||
get count(): number {
|
||||
@@ -184,18 +180,6 @@ const virtualWrapperStyle = computed<CSSProperties>(() => ({
|
||||
height: `${virtualizer.value.getTotalSize()}px`
|
||||
})
|
||||
}))
|
||||
const {
|
||||
activeDetails,
|
||||
clearHoverTimers,
|
||||
resetActiveDetails,
|
||||
scheduleDetailsHide,
|
||||
scheduleDetailsShow
|
||||
} = useJobDetailsHover<{ jobId: string; workflowId?: string }>({
|
||||
getActiveId: (details) => details.jobId,
|
||||
getDisplayedJobGroups: () => displayedJobGroups,
|
||||
onReset: clearPopoverAnchor
|
||||
})
|
||||
|
||||
function getVirtualRowStyle(virtualItem: VirtualItem): CSSProperties {
|
||||
return {
|
||||
position: 'absolute',
|
||||
@@ -229,22 +213,88 @@ function onListScroll() {
|
||||
|
||||
function clearPopoverAnchor() {
|
||||
activeRowElement.value = null
|
||||
popoverPosition.value = null
|
||||
}
|
||||
|
||||
function updatePopoverPosition() {
|
||||
const rowElement = activeRowElement.value
|
||||
if (!rowElement) return
|
||||
function clearHideTimer() {
|
||||
if (hideTimer.value !== null) {
|
||||
clearTimeout(hideTimer.value)
|
||||
hideTimer.value = null
|
||||
}
|
||||
hideTimerJobId.value = null
|
||||
}
|
||||
|
||||
const rect = rowElement.getBoundingClientRect()
|
||||
popoverPosition.value = getHoverPopoverPosition(rect, window.innerWidth)
|
||||
function clearShowTimer() {
|
||||
if (showTimer.value !== null) {
|
||||
clearTimeout(showTimer.value)
|
||||
showTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearHoverTimers() {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
}
|
||||
|
||||
function resetActiveDetails() {
|
||||
clearHoverTimers()
|
||||
isDetailsOpen.value = false
|
||||
activeDetails.value = null
|
||||
clearPopoverAnchor()
|
||||
}
|
||||
|
||||
function hasDisplayedJob(jobId: string) {
|
||||
return displayedJobGroups.some((group) =>
|
||||
group.items.some((item) => item.id === jobId)
|
||||
)
|
||||
}
|
||||
|
||||
function scheduleDetailsShow(nextActive: {
|
||||
jobId: string
|
||||
workflowId?: string
|
||||
}) {
|
||||
clearShowTimer()
|
||||
showTimer.value = window.setTimeout(() => {
|
||||
showTimer.value = null
|
||||
if (!hasDisplayedJob(nextActive.jobId)) return
|
||||
|
||||
activeDetails.value = nextActive
|
||||
isDetailsOpen.value = true
|
||||
}, DETAILS_SHOW_DELAY_MS)
|
||||
}
|
||||
|
||||
function showDetailsNow(nextActive: { jobId: string; workflowId?: string }) {
|
||||
clearHoverTimers()
|
||||
if (!hasDisplayedJob(nextActive.jobId)) return
|
||||
|
||||
activeDetails.value = nextActive
|
||||
isDetailsOpen.value = true
|
||||
}
|
||||
|
||||
function scheduleDetailsHide(jobId?: string) {
|
||||
if (!jobId) return
|
||||
|
||||
clearShowTimer()
|
||||
if (hideTimerJobId.value && hideTimerJobId.value !== jobId) {
|
||||
return
|
||||
}
|
||||
|
||||
clearHideTimer()
|
||||
hideTimerJobId.value = jobId
|
||||
hideTimer.value = window.setTimeout(() => {
|
||||
const currentActive = activeDetails.value
|
||||
if (currentActive?.jobId === jobId) {
|
||||
isDetailsOpen.value = false
|
||||
}
|
||||
hideTimer.value = null
|
||||
hideTimerJobId.value = null
|
||||
}, DETAILS_HIDE_DELAY_MS)
|
||||
}
|
||||
|
||||
function onJobLeave(jobId: string) {
|
||||
if (hoveredJobId.value === jobId) {
|
||||
hoveredJobId.value = null
|
||||
}
|
||||
scheduleDetailsHide(jobId, clearPopoverAnchor)
|
||||
scheduleDetailsHide(jobId)
|
||||
}
|
||||
|
||||
function onJobEnter(job: JobListItem, event: MouseEvent) {
|
||||
@@ -254,22 +304,22 @@ function onJobEnter(job: JobListItem, event: MouseEvent) {
|
||||
if (!(rowElement instanceof HTMLElement)) return
|
||||
|
||||
activeRowElement.value = rowElement
|
||||
if (activeDetails.value?.jobId === job.id) {
|
||||
const nextActive = {
|
||||
jobId: job.id,
|
||||
workflowId: job.taskRef?.workflowId
|
||||
}
|
||||
|
||||
if (isDetailsOpen.value && activeDetails.value?.jobId === job.id) {
|
||||
clearHoverTimers()
|
||||
void nextTick(updatePopoverPosition)
|
||||
return
|
||||
}
|
||||
|
||||
scheduleDetailsShow(
|
||||
{
|
||||
jobId: job.id,
|
||||
workflowId: job.taskRef?.workflowId
|
||||
},
|
||||
() => {
|
||||
activeRowElement.value = rowElement
|
||||
void nextTick(updatePopoverPosition)
|
||||
}
|
||||
)
|
||||
const isSwitchingVisibleDetails = isDetailsOpen.value
|
||||
const showDetails = isSwitchingVisibleDetails
|
||||
? showDetailsNow
|
||||
: scheduleDetailsShow
|
||||
|
||||
showDetails(nextActive)
|
||||
}
|
||||
|
||||
function isCancelable(job: JobListItem) {
|
||||
@@ -287,7 +337,7 @@ function getPreviewOutput(job: JobListItem) {
|
||||
function getJobPreviewUrl(job: JobListItem) {
|
||||
const preview = getPreviewOutput(job)
|
||||
if (preview?.isImage || preview?.isVideo) {
|
||||
return preview.url
|
||||
return preview.previewUrl
|
||||
}
|
||||
return job.iconImageUrl
|
||||
}
|
||||
@@ -327,7 +377,13 @@ function onPopoverEnter() {
|
||||
}
|
||||
|
||||
function onPopoverLeave() {
|
||||
scheduleDetailsHide(activeDetails.value?.jobId, clearPopoverAnchor)
|
||||
scheduleDetailsHide(activeDetails.value?.jobId)
|
||||
}
|
||||
|
||||
function onPopoverOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
resetActiveDetails()
|
||||
}
|
||||
}
|
||||
|
||||
function getJobIconClass(job: JobListItem): string | undefined {
|
||||
@@ -337,4 +393,18 @@ function getJobIconClass(job: JobListItem): string | undefined {
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
watch(
|
||||
() => displayedJobGroups,
|
||||
() => {
|
||||
const currentActive = activeDetails.value
|
||||
if (!currentActive) return
|
||||
|
||||
if (!hasDisplayedJob(currentActive.jobId)) {
|
||||
resetActiveDetails()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(resetActiveDetails)
|
||||
</script>
|
||||
|
||||
62
src/components/queue/job/JobDetailsHoverPopover.vue
Normal file
62
src/components/queue/job/JobDetailsHoverPopover.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<Popover :open="isOpen" @update:open="onOpenChange">
|
||||
<PopoverContent
|
||||
v-if="hasReference"
|
||||
:reference="referenceElement ?? undefined"
|
||||
data-testid="queue-job-details-popover"
|
||||
side="right"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:collision-padding="8"
|
||||
:avoid-collisions="true"
|
||||
:side-flip="true"
|
||||
:hide-when-detached="true"
|
||||
position-strategy="fixed"
|
||||
sticky="always"
|
||||
class="job-details-popover z-1700 max-h-(--reka-popover-content-available-height) w-auto overflow-y-auto border-0 bg-transparent p-0 shadow-none will-change-transform"
|
||||
@mouseenter="$emit('content-enter')"
|
||||
@mouseleave="$emit('content-leave')"
|
||||
@open-auto-focus.prevent
|
||||
@close-auto-focus.prevent
|
||||
>
|
||||
<JobDetailsPopover
|
||||
v-if="jobId"
|
||||
:job-id="jobId"
|
||||
:workflow-id="workflowId"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
|
||||
const {
|
||||
open,
|
||||
jobId = null,
|
||||
workflowId,
|
||||
referenceElement = null
|
||||
} = defineProps<{
|
||||
open: boolean
|
||||
jobId?: string | null
|
||||
workflowId?: string
|
||||
referenceElement?: HTMLElement | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'content-enter'): void
|
||||
(e: 'content-leave'): void
|
||||
(e: 'update:open', open: boolean): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed(() => open && !!jobId)
|
||||
const hasReference = computed(() => !!jobId && !!referenceElement)
|
||||
|
||||
function onOpenChange(nextOpen: boolean) {
|
||||
emit('update:open', nextOpen)
|
||||
}
|
||||
</script>
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getHoverPopoverPosition } from './getHoverPopoverPosition'
|
||||
|
||||
describe('getHoverPopoverPosition', () => {
|
||||
it('places the popover to the right when space is available', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 100, left: 40, right: 240 },
|
||||
1280
|
||||
)
|
||||
expect(position).toEqual({ top: 100, left: 248 })
|
||||
})
|
||||
|
||||
it('places the popover to the left when right space is insufficient', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 100, left: 980, right: 1180 },
|
||||
1280
|
||||
)
|
||||
expect(position).toEqual({ top: 100, left: 672 })
|
||||
})
|
||||
|
||||
it('clamps the top to viewport padding when rect.top is near the top edge', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 2, left: 40, right: 240 },
|
||||
1280
|
||||
)
|
||||
expect(position).toEqual({ top: 8, left: 248 })
|
||||
})
|
||||
|
||||
it('clamps left to viewport padding when fallback would go off-screen', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 100, left: 100, right: 300 },
|
||||
320
|
||||
)
|
||||
expect(position).toEqual({ top: 100, left: 8 })
|
||||
})
|
||||
|
||||
it('prefers right when both sides have equal space', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 200, left: 340, right: 640 },
|
||||
1280
|
||||
)
|
||||
expect(position).toEqual({ top: 200, left: 648 })
|
||||
})
|
||||
|
||||
it('falls back to left when right space is less than popover width', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 100, left: 600, right: 1000 },
|
||||
1280
|
||||
)
|
||||
expect(position).toEqual({ top: 100, left: 292 })
|
||||
})
|
||||
|
||||
it('handles narrow viewport where popover barely fits', () => {
|
||||
const position = getHoverPopoverPosition(
|
||||
{ top: 50, left: 8, right: 100 },
|
||||
316
|
||||
)
|
||||
expect(position).toEqual({ top: 50, left: 8 })
|
||||
})
|
||||
})
|
||||
@@ -1,39 +0,0 @@
|
||||
const POPOVER_GAP = 8
|
||||
const POPOVER_WIDTH = 300
|
||||
const VIEWPORT_PADDING = 8
|
||||
|
||||
type AnchorRect = Pick<DOMRect, 'top' | 'left' | 'right'>
|
||||
|
||||
type HoverPopoverPosition = {
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
|
||||
export function getHoverPopoverPosition(
|
||||
rect: AnchorRect,
|
||||
viewportWidth: number
|
||||
): HoverPopoverPosition {
|
||||
const availableLeft = rect.left - POPOVER_GAP
|
||||
const availableRight = viewportWidth - rect.right - POPOVER_GAP
|
||||
const preferredLeft = rect.right + POPOVER_GAP
|
||||
const fallbackLeft = rect.left - POPOVER_WIDTH - POPOVER_GAP
|
||||
const maxLeft = Math.max(
|
||||
VIEWPORT_PADDING,
|
||||
viewportWidth - POPOVER_WIDTH - VIEWPORT_PADDING
|
||||
)
|
||||
|
||||
if (
|
||||
availableRight >= POPOVER_WIDTH &&
|
||||
(availableRight >= availableLeft || availableLeft < POPOVER_WIDTH)
|
||||
) {
|
||||
return {
|
||||
top: Math.max(VIEWPORT_PADDING, rect.top),
|
||||
left: Math.min(maxLeft, preferredLeft)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
top: Math.max(VIEWPORT_PADDING, rect.top),
|
||||
left: Math.max(VIEWPORT_PADDING, Math.min(maxLeft, fallbackLeft))
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@
|
||||
</Button>
|
||||
<div
|
||||
v-if="isIntegratedTabBar"
|
||||
data-testid="integrated-tab-bar-actions"
|
||||
class="ml-auto flex shrink-0 items-center gap-2 px-2"
|
||||
>
|
||||
<Button
|
||||
|
||||
16
src/components/ui/popover/Popover.vue
Normal file
16
src/components/ui/popover/Popover.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
|
||||
import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<PopoverRootProps>()
|
||||
const emits = defineEmits<PopoverRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
49
src/components/ui/popover/PopoverContent.vue
Normal file
49
src/components/ui/popover/PopoverContent.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
|
||||
import { PopoverContent, PopoverPortal, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
class: className,
|
||||
...restProps
|
||||
} = defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emits = defineEmits<PopoverContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => ({
|
||||
align,
|
||||
sideOffset,
|
||||
...restProps
|
||||
}))
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'z-50 w-72 rounded-md border bg-base-background p-4 text-base-foreground shadow-md outline-none',
|
||||
'data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</template>
|
||||
@@ -831,6 +831,58 @@ describe('scan skips interior of bypassed subgraph containers', () => {
|
||||
|
||||
expect(useMissingModelStore().missingModelCandidates).toBeNull()
|
||||
})
|
||||
|
||||
it('skips nested subgraph containers during parent subgraph replay scan', async () => {
|
||||
const rootGraph = new LGraph()
|
||||
const outerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const innerSubgraph = createTestSubgraph({ rootGraph })
|
||||
const leafNode = new LGraphNode('UNETLoader')
|
||||
innerSubgraph.add(leafNode)
|
||||
|
||||
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
||||
parentGraph: outerSubgraph,
|
||||
id: 76
|
||||
})
|
||||
outerSubgraph.add(innerSubgraphNode)
|
||||
|
||||
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
|
||||
parentGraph: rootGraph,
|
||||
id: 205
|
||||
})
|
||||
rootGraph.add(outerSubgraphNode)
|
||||
|
||||
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(rootGraph)
|
||||
const modelScanSpy = vi
|
||||
.spyOn(missingModelScan, 'scanNodeModelCandidates')
|
||||
.mockReturnValue([])
|
||||
const mediaScanSpy = vi
|
||||
.spyOn(missingMediaScan, 'scanNodeMediaCandidates')
|
||||
.mockReturnValue([])
|
||||
|
||||
installErrorClearingHooks(rootGraph)
|
||||
|
||||
rootGraph.onNodeAdded?.(outerSubgraphNode)
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(modelScanSpy).toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
leafNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(modelScanSpy).not.toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
innerSubgraphNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mediaScanSpy).toHaveBeenCalledWith(rootGraph, leafNode, false)
|
||||
expect(mediaScanSpy).not.toHaveBeenCalledWith(
|
||||
rootGraph,
|
||||
innerSubgraphNode,
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearWidgetRelatedErrors parameter routing', () => {
|
||||
|
||||
@@ -162,6 +162,7 @@ function scanAndAddNodeErrors(node: LGraphNode): void {
|
||||
|
||||
if (node.isSubgraphNode?.() && node.subgraph) {
|
||||
for (const innerNode of collectAllNodes(node.subgraph)) {
|
||||
if (innerNode.isSubgraphNode?.()) continue
|
||||
if (isNodeInactive(innerNode.mode)) continue
|
||||
scanSingleNodeErrors(innerNode)
|
||||
}
|
||||
|
||||
124
src/composables/maskeditor/gpuUtils.test.ts
Normal file
124
src/composables/maskeditor/gpuUtils.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildStrokePoints, clampDirtyRect } from './gpuUtils'
|
||||
|
||||
const uninit = {
|
||||
minX: Infinity,
|
||||
minY: Infinity,
|
||||
maxX: -Infinity,
|
||||
maxY: -Infinity
|
||||
}
|
||||
|
||||
describe('clampDirtyRect', () => {
|
||||
it('returns full canvas when dirty rect is uninitialised', () => {
|
||||
expect(clampDirtyRect(uninit, 100, 200)).toEqual({
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
dw: 100,
|
||||
dh: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the clamped rect when fully inside canvas bounds', () => {
|
||||
const rect = { minX: 10, minY: 20, maxX: 60, maxY: 90 }
|
||||
expect(clampDirtyRect(rect, 100, 200)).toEqual({
|
||||
dx: 10,
|
||||
dy: 20,
|
||||
dw: 50,
|
||||
dh: 70
|
||||
})
|
||||
})
|
||||
|
||||
it('clamps rect that extends beyond canvas edges', () => {
|
||||
const rect = { minX: -5, minY: -10, maxX: 120, maxY: 250 }
|
||||
expect(clampDirtyRect(rect, 100, 200)).toEqual({
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
dw: 100,
|
||||
dh: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('returns full canvas when the clamped area has zero width', () => {
|
||||
const rect = { minX: 50, minY: 10, maxX: 50, maxY: 80 }
|
||||
expect(clampDirtyRect(rect, 100, 200)).toEqual({
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
dw: 100,
|
||||
dh: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('returns full canvas when the clamped area has zero height', () => {
|
||||
const rect = { minX: 10, minY: 50, maxX: 80, maxY: 50 }
|
||||
expect(clampDirtyRect(rect, 100, 200)).toEqual({
|
||||
dx: 0,
|
||||
dy: 0,
|
||||
dw: 100,
|
||||
dh: 200
|
||||
})
|
||||
})
|
||||
|
||||
it('floors dx/dy and ceils the far edges', () => {
|
||||
const rect = { minX: 10.7, minY: 20.3, maxX: 59.2, maxY: 89.9 }
|
||||
const result = clampDirtyRect(rect, 100, 200)
|
||||
expect(result.dx).toBe(10)
|
||||
expect(result.dy).toBe(20)
|
||||
expect(result.dw).toBe(60 - 10) // ceil(59.2)=60, dx=10
|
||||
expect(result.dh).toBe(90 - 20) // ceil(89.9)=90, dy=20
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildStrokePoints', () => {
|
||||
it('returns input points as-is when skipResampling is true', () => {
|
||||
const points = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 100, y: 100 }
|
||||
]
|
||||
const result = buildStrokePoints(points, true, 10)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual({ x: 0, y: 0, pressure: 1.0 })
|
||||
expect(result[1]).toEqual({ x: 100, y: 100, pressure: 1.0 })
|
||||
})
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(buildStrokePoints([], false, 10)).toHaveLength(0)
|
||||
expect(buildStrokePoints([], true, 10)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty array for a single point (no segments to interpolate)', () => {
|
||||
expect(buildStrokePoints([{ x: 5, y: 5 }], false, 10)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('interpolates a horizontal segment into multiple evenly-spaced points', () => {
|
||||
const points = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 30, y: 0 }
|
||||
]
|
||||
const result = buildStrokePoints(points, false, 10)
|
||||
// 30px distance / 10 stepSize = 3 steps → 4 points (s=0,1,2,3)
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0]).toMatchObject({ x: 0, y: 0 })
|
||||
expect(result[3]).toMatchObject({ x: 30, y: 0 })
|
||||
result.forEach((p) => expect(p.pressure).toBe(1.0))
|
||||
})
|
||||
|
||||
it('uses at least one step when points are very close together', () => {
|
||||
const points = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0.1, y: 0 }
|
||||
]
|
||||
// distance 0.1 < stepSize 10 → steps=1 → 2 points
|
||||
const result = buildStrokePoints(points, false, 10)
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('interpolates all pressure values to 1.0', () => {
|
||||
const points = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 50, y: 50 }
|
||||
]
|
||||
const result = buildStrokePoints(points, false, 10)
|
||||
result.forEach((p) => expect(p.pressure).toBe(1.0))
|
||||
})
|
||||
})
|
||||
60
src/composables/maskeditor/gpuUtils.ts
Normal file
60
src/composables/maskeditor/gpuUtils.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
|
||||
import type { DirtyRect } from './brushDrawingUtils'
|
||||
|
||||
/**
|
||||
* Computes the clamped dirty-rect coordinates for a putImageData call.
|
||||
*
|
||||
* Returns the full canvas dimensions when the dirty rect is uninitialised
|
||||
* (Infinity sentinels) or the resulting area has zero/negative size.
|
||||
*/
|
||||
export function clampDirtyRect(
|
||||
rect: DirtyRect,
|
||||
canvasWidth: number,
|
||||
canvasHeight: number
|
||||
): { dx: number; dy: number; dw: number; dh: number } {
|
||||
const full = { dx: 0, dy: 0, dw: canvasWidth, dh: canvasHeight }
|
||||
if (rect.minX === Infinity || rect.maxX === -Infinity) return full
|
||||
|
||||
const dx = Math.floor(Math.max(0, rect.minX))
|
||||
const dy = Math.floor(Math.max(0, rect.minY))
|
||||
const dw = Math.ceil(Math.min(canvasWidth, rect.maxX)) - dx
|
||||
const dh = Math.ceil(Math.min(canvasHeight, rect.maxY)) - dy
|
||||
|
||||
return dw > 0 && dh > 0 ? { dx, dy, dw, dh } : full
|
||||
}
|
||||
|
||||
/**
|
||||
* Linearly interpolates a sequence of points at a fixed step size,
|
||||
* returning GPU-ready stroke points with pressure=1.
|
||||
*
|
||||
* When skipResampling is true the input points are returned as-is (used
|
||||
* during live preview where the caller has already handled spacing).
|
||||
*/
|
||||
export function buildStrokePoints(
|
||||
points: Point[],
|
||||
skipResampling: boolean,
|
||||
stepSize: number
|
||||
): { x: number; y: number; pressure: number }[] {
|
||||
if (skipResampling) {
|
||||
return points.map((p) => ({ x: p.x, y: p.y, pressure: 1.0 }))
|
||||
}
|
||||
const result: { x: number; y: number; pressure: number }[] = []
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p1 = points[i]
|
||||
const p2 = points[i + 1]
|
||||
const steps = Math.max(
|
||||
1,
|
||||
Math.ceil(Math.hypot(p2.x - p1.x, p2.y - p1.y) / stepSize)
|
||||
)
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const t = s / steps
|
||||
result.push({
|
||||
x: p1.x + (p2.x - p1.x) * t,
|
||||
y: p1.y + (p2.y - p1.y) * t,
|
||||
pressure: 1.0
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
295
src/composables/maskeditor/useBrushDrawing.test.ts
Normal file
295
src/composables/maskeditor/useBrushDrawing.test.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, ref } from 'vue'
|
||||
import type { EffectScope } from 'vue'
|
||||
|
||||
// vi.hoisted runs before imports — only vi.fn() is safe here (no Vue)
|
||||
const saveStateSpy = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockStoreDef = vi.hoisted(() => ({
|
||||
brushSettings: {
|
||||
size: 20,
|
||||
hardness: 0.9,
|
||||
opacity: 1,
|
||||
stepSize: 5,
|
||||
type: 'arc' as string
|
||||
},
|
||||
currentTool: 'pen' as string,
|
||||
activeLayer: 'mask' as string,
|
||||
maskCanvas: null as HTMLCanvasElement | null,
|
||||
maskCtx: null as CanvasRenderingContext2D | null,
|
||||
rgbCanvas: null as HTMLCanvasElement | null,
|
||||
rgbCtx: null as CanvasRenderingContext2D | null,
|
||||
maskBlendMode: 'black',
|
||||
maskOpacity: 0.8,
|
||||
maskColor: { r: 0, g: 0, b: 0 },
|
||||
rgbColor: '#FF0000',
|
||||
canvasHistory: { saveState: saveStateSpy }
|
||||
}))
|
||||
|
||||
// vi.mock factory runs after hoisting — ref/computed from Vue are available
|
||||
vi.mock('./useGPUResources', () => {
|
||||
// Singletons shared across all calls to useGPUResources() in this test file
|
||||
const isSavingHistory = ref(false)
|
||||
const dirtyRect = ref({
|
||||
minX: Infinity,
|
||||
minY: Infinity,
|
||||
maxX: -Infinity,
|
||||
maxY: -Infinity
|
||||
})
|
||||
const hasRenderer = ref(false)
|
||||
const previewCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const prepareStroke = vi.fn()
|
||||
const clearPreview = vi.fn()
|
||||
const compositeStroke = vi.fn()
|
||||
const copyGpuToCanvas = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ maskData: undefined, rgbData: undefined })
|
||||
return {
|
||||
useGPUResources: () => ({
|
||||
isSavingHistory,
|
||||
dirtyRect,
|
||||
hasRenderer,
|
||||
previewCanvas,
|
||||
prepareStroke,
|
||||
clearPreview,
|
||||
compositeStroke,
|
||||
copyGpuToCanvas,
|
||||
gpuRender: vi.fn(),
|
||||
gpuDrawPoint: vi.fn(),
|
||||
clearGPU: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
initGPUResources: vi.fn().mockResolvedValue(undefined),
|
||||
initPreviewCanvas: vi.fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./useCoordinateTransform', () => ({
|
||||
useCoordinateTransform: () => ({
|
||||
screenToCanvas: vi.fn(({ x, y }: { x: number; y: number }) => ({ x, y }))
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./useBrushPersistence', () => ({
|
||||
useBrushPersistence: () => ({ loadAndApply: vi.fn(), save: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('./useBrushAdjustment', () => ({
|
||||
useBrushAdjustment: () => ({
|
||||
startBrushAdjustment: vi.fn(),
|
||||
handleBrushAdjustment: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStoreDef)
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { registerExtension: vi.fn() }
|
||||
}))
|
||||
|
||||
import { useGPUResources } from './useGPUResources'
|
||||
import { useBrushDrawing } from './useBrushDrawing'
|
||||
|
||||
function makePointerEvent(
|
||||
x: number,
|
||||
y: number,
|
||||
opts: { buttons?: number; shiftKey?: boolean } = {}
|
||||
): PointerEvent {
|
||||
return {
|
||||
offsetX: x,
|
||||
offsetY: y,
|
||||
buttons: opts.buttons ?? 1,
|
||||
shiftKey: opts.shiftKey ?? false,
|
||||
preventDefault: vi.fn()
|
||||
} as unknown as PointerEvent
|
||||
}
|
||||
|
||||
function makeMockCtx(): CanvasRenderingContext2D {
|
||||
const gradient = { addColorStop: vi.fn() }
|
||||
return {
|
||||
beginPath: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fillStyle: '',
|
||||
drawImage: vi.fn(),
|
||||
createRadialGradient: vi.fn(() => gradient),
|
||||
globalCompositeOperation: 'source-over'
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
}
|
||||
|
||||
let scope: EffectScope | null = null
|
||||
|
||||
function setup() {
|
||||
scope = effectScope()
|
||||
return scope.run(() => useBrushDrawing())!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
const mockCtx = makeMockCtx()
|
||||
const mockCanvas = {
|
||||
width: 200,
|
||||
height: 200,
|
||||
style: { opacity: '' }
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
mockStoreDef.maskCanvas = mockCanvas
|
||||
mockStoreDef.maskCtx = mockCtx
|
||||
mockStoreDef.rgbCanvas = mockCanvas
|
||||
mockStoreDef.rgbCtx = mockCtx
|
||||
mockStoreDef.currentTool = 'pen'
|
||||
mockStoreDef.activeLayer = 'mask'
|
||||
|
||||
const gpu = useGPUResources()
|
||||
gpu.isSavingHistory.value = false
|
||||
gpu.hasRenderer.value = false
|
||||
gpu.previewCanvas.value = null
|
||||
gpu.dirtyRect.value = {
|
||||
minX: Infinity,
|
||||
minY: Infinity,
|
||||
maxX: -Infinity,
|
||||
maxY: -Infinity
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = null
|
||||
})
|
||||
|
||||
describe('startDrawing', () => {
|
||||
it('calls prepareStroke on the GPU resources', async () => {
|
||||
const { startDrawing } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
expect(useGPUResources().prepareStroke).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('sets DestinationOut composition when tool is eraser', async () => {
|
||||
mockStoreDef.currentTool = 'eraser'
|
||||
const { startDrawing } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe(
|
||||
'destination-out'
|
||||
)
|
||||
})
|
||||
|
||||
it('sets SourceOver composition when tool is mask pen', async () => {
|
||||
const { startDrawing } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe('source-over')
|
||||
})
|
||||
|
||||
it('sets DestinationOut composition when right mouse button is used', async () => {
|
||||
const { startDrawing } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50, { buttons: 2 }))
|
||||
expect(mockStoreDef.maskCtx!.globalCompositeOperation).toBe(
|
||||
'destination-out'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startDrawing error handling', () => {
|
||||
it('catches initShape errors and resets drawing state', async () => {
|
||||
mockStoreDef.maskCtx = null
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { startDrawing } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
'[useBrushDrawing] Failed to start drawing:',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(mockStoreDef.maskCtx).toBeNull()
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('startDrawing shift+click', () => {
|
||||
it('draws a line from the previous point when shift is held', async () => {
|
||||
const { startDrawing } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await startDrawing(makePointerEvent(100, 50, { shiftKey: true }))
|
||||
expect(
|
||||
(mockStoreDef.maskCtx as unknown as ReturnType<typeof makeMockCtx>)
|
||||
.beginPath
|
||||
).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleDrawing', () => {
|
||||
it('updates smoothingLastDrawTime after each move event', async () => {
|
||||
const rafSpy = vi
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((cb) => {
|
||||
cb(0)
|
||||
return 0
|
||||
})
|
||||
const { startDrawing, handleDrawing } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await handleDrawing(makePointerEvent(55, 55))
|
||||
expect(rafSpy).toHaveBeenCalled()
|
||||
rafSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('drawEnd canvas visibility', () => {
|
||||
it('restores rgb canvas opacity when activeLayer is rgb', async () => {
|
||||
mockStoreDef.activeLayer = 'rgb'
|
||||
const mockRgbCanvas = {
|
||||
width: 200,
|
||||
height: 200,
|
||||
style: { opacity: '' }
|
||||
} as unknown as HTMLCanvasElement
|
||||
mockStoreDef.rgbCanvas = mockRgbCanvas
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await drawEnd(makePointerEvent(60, 60))
|
||||
expect(mockRgbCanvas.style.opacity).toBe('1')
|
||||
})
|
||||
|
||||
it('restores preview canvas opacity to 1 after drawEnd', async () => {
|
||||
const gpu = useGPUResources()
|
||||
const mockPreviewCanvas = {
|
||||
style: { opacity: '' }
|
||||
} as unknown as HTMLCanvasElement
|
||||
gpu.previewCanvas.value = mockPreviewCanvas
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await drawEnd(makePointerEvent(60, 60))
|
||||
expect(mockPreviewCanvas.style.opacity).toBe('1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('drawEnd', () => {
|
||||
it('calls compositeStroke indicating the active layer and erasing state', async () => {
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await drawEnd(makePointerEvent(60, 60))
|
||||
expect(useGPUResources().compositeStroke).toHaveBeenCalledOnce()
|
||||
expect(useGPUResources().compositeStroke).toHaveBeenCalledWith(false, false)
|
||||
})
|
||||
|
||||
it('calls clearPreview to clean up the GPU overlay', async () => {
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await drawEnd(makePointerEvent(60, 60))
|
||||
expect(useGPUResources().clearPreview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('saves canvas history on stroke completion', async () => {
|
||||
const { startDrawing, drawEnd } = setup()
|
||||
await startDrawing(makePointerEvent(50, 50))
|
||||
await drawEnd(makePointerEvent(60, 60))
|
||||
expect(saveStateSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('is a no-op when drawing was never started', async () => {
|
||||
const { drawEnd } = setup()
|
||||
await drawEnd(makePointerEvent(60, 60))
|
||||
expect(useGPUResources().compositeStroke).not.toHaveBeenCalled()
|
||||
expect(saveStateSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
192
src/composables/maskeditor/useGPUResources.test.ts
Normal file
192
src/composables/maskeditor/useGPUResources.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { effectScope, nextTick, reactive } from 'vue'
|
||||
import type { EffectScope } from 'vue'
|
||||
|
||||
vi.mock('typegpu', () => ({
|
||||
tgpu: {
|
||||
init: vi.fn().mockRejectedValue(new Error('WebGPU not supported'))
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./gpu/GPUBrushRenderer', () => ({
|
||||
GPUBrushRenderer: vi.fn()
|
||||
}))
|
||||
|
||||
const mockStore = reactive({
|
||||
tgpuRoot: null as unknown,
|
||||
maskCanvas: null as HTMLCanvasElement | null,
|
||||
rgbCanvas: null as HTMLCanvasElement | null,
|
||||
maskCtx: null as CanvasRenderingContext2D | null,
|
||||
rgbCtx: null as CanvasRenderingContext2D | null,
|
||||
clearTrigger: 0,
|
||||
canvasHistory: { currentStateIndex: 0 },
|
||||
gpuTexturesNeedRecreation: false,
|
||||
gpuTextureWidth: 0,
|
||||
gpuTextureHeight: 0,
|
||||
pendingGPUMaskData: null as null,
|
||||
pendingGPURgbData: null as null,
|
||||
brushSettings: {
|
||||
size: 20,
|
||||
hardness: 0.9,
|
||||
opacity: 1,
|
||||
stepSize: 5,
|
||||
type: 'arc'
|
||||
},
|
||||
activeLayer: 'mask',
|
||||
currentTool: 'pen',
|
||||
maskColor: { r: 0, g: 0, b: 0 },
|
||||
rgbColor: '#FF0000'
|
||||
})
|
||||
|
||||
vi.mock('@/stores/maskEditorStore', () => ({
|
||||
useMaskEditorStore: vi.fn(() => mockStore)
|
||||
}))
|
||||
|
||||
import { resetDirtyRect } from './brushDrawingUtils'
|
||||
import { useGPUResources } from './useGPUResources'
|
||||
|
||||
let scope: EffectScope | null = null
|
||||
|
||||
function setup() {
|
||||
scope = effectScope()
|
||||
return scope.run(() => useGPUResources())!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockStore.tgpuRoot = null
|
||||
mockStore.maskCanvas = null
|
||||
mockStore.rgbCanvas = null
|
||||
mockStore.maskCtx = null
|
||||
mockStore.rgbCtx = null
|
||||
mockStore.clearTrigger = 0
|
||||
mockStore.canvasHistory.currentStateIndex = 0
|
||||
mockStore.gpuTexturesNeedRecreation = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope?.stop()
|
||||
scope = null
|
||||
})
|
||||
|
||||
describe('initial reactive state', () => {
|
||||
it('hasRenderer is false when no renderer exists', () => {
|
||||
const { hasRenderer } = setup()
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
|
||||
it('isSavingHistory is false initially', () => {
|
||||
const { isSavingHistory } = setup()
|
||||
expect(isSavingHistory.value).toBe(false)
|
||||
})
|
||||
|
||||
it('previewCanvas is null initially', () => {
|
||||
const { previewCanvas } = setup()
|
||||
expect(previewCanvas.value).toBeNull()
|
||||
})
|
||||
|
||||
it('dirtyRect starts with uninitialised sentinel values', () => {
|
||||
const { dirtyRect } = setup()
|
||||
expect(dirtyRect.value).toEqual(resetDirtyRect())
|
||||
})
|
||||
})
|
||||
|
||||
describe('no-op when GPU is not initialised', () => {
|
||||
it('prepareStroke does not throw', () => {
|
||||
const { prepareStroke } = setup()
|
||||
expect(() => prepareStroke()).not.toThrow()
|
||||
})
|
||||
|
||||
it('clearPreview does not throw', () => {
|
||||
const { clearPreview } = setup()
|
||||
expect(() => clearPreview()).not.toThrow()
|
||||
})
|
||||
|
||||
it('clearGPU does not throw', () => {
|
||||
const { clearGPU } = setup()
|
||||
expect(() => clearGPU()).not.toThrow()
|
||||
})
|
||||
|
||||
it('destroy does not throw', () => {
|
||||
const { destroy } = setup()
|
||||
expect(() => destroy()).not.toThrow()
|
||||
})
|
||||
|
||||
it('gpuRender does not throw with empty or non-empty point arrays', () => {
|
||||
const { gpuRender } = setup()
|
||||
expect(() => gpuRender([])).not.toThrow()
|
||||
expect(() => gpuRender([{ x: 10, y: 20 }])).not.toThrow()
|
||||
})
|
||||
|
||||
it('compositeStroke does not throw for any combination of flags', () => {
|
||||
const { compositeStroke } = setup()
|
||||
expect(() => compositeStroke(false, false)).not.toThrow()
|
||||
expect(() => compositeStroke(true, true)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initGPUResources', () => {
|
||||
it('leaves hasRenderer false when TypeGPU initialisation fails', async () => {
|
||||
const { initGPUResources, hasRenderer } = setup()
|
||||
await initGPUResources()
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyGpuToCanvas', () => {
|
||||
it('rejects with a descriptive error when GPU resources are not ready', async () => {
|
||||
const { copyGpuToCanvas } = setup()
|
||||
await expect(copyGpuToCanvas()).rejects.toThrow('GPU resources not ready')
|
||||
})
|
||||
})
|
||||
|
||||
describe('watchers', () => {
|
||||
it('clearTrigger watcher calls clearGPU without throwing', async () => {
|
||||
setup()
|
||||
mockStore.clearTrigger++
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
it('currentStateIndex watcher short-circuits when isSavingHistory is true', async () => {
|
||||
const { isSavingHistory } = setup()
|
||||
isSavingHistory.value = true
|
||||
mockStore.canvasHistory.currentStateIndex++
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
it('currentStateIndex watcher calls updateGPUFromCanvas when not saving history', async () => {
|
||||
setup()
|
||||
mockStore.canvasHistory.currentStateIndex++
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
it('gpuTexturesNeedRecreation watcher returns early when device is not initialised', async () => {
|
||||
setup()
|
||||
mockStore.gpuTexturesNeedRecreation = true
|
||||
await nextTick()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initGPUResources with pre-existing tgpuRoot', () => {
|
||||
it('returns early with a warning when canvas contexts are not ready', async () => {
|
||||
const { initGPUResources, hasRenderer } = setup()
|
||||
mockStore.tgpuRoot = { device: {} } as unknown
|
||||
await initGPUResources()
|
||||
expect(hasRenderer.value).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('initPreviewCanvas', () => {
|
||||
it('returns early when device is not initialised', () => {
|
||||
const { initPreviewCanvas } = setup()
|
||||
const canvas = document.createElement('canvas')
|
||||
expect(() => initPreviewCanvas(canvas)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('gpuDrawPoint', () => {
|
||||
it('resolves immediately when renderer is not initialised', async () => {
|
||||
const { gpuDrawPoint } = setup()
|
||||
await expect(gpuDrawPoint({ x: 10, y: 20 })).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
624
src/composables/maskeditor/useGPUResources.ts
Normal file
624
src/composables/maskeditor/useGPUResources.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
/// <reference types="@webgpu/types" />
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { tgpu } from 'typegpu'
|
||||
|
||||
import { BrushShape } from '@/extensions/core/maskeditor/types'
|
||||
import type { Point } from '@/extensions/core/maskeditor/types'
|
||||
import { useMaskEditorStore } from '@/stores/maskEditorStore'
|
||||
import { parseToRgb } from '@/utils/colorUtil'
|
||||
|
||||
import type { DirtyRect } from './brushDrawingUtils'
|
||||
import {
|
||||
premultiplyData,
|
||||
resetDirtyRect,
|
||||
updateDirtyRect
|
||||
} from './brushDrawingUtils'
|
||||
import { getEffectiveBrushSize, getEffectiveHardness } from './brushUtils'
|
||||
import { GPUBrushRenderer } from './gpu/GPUBrushRenderer'
|
||||
import { buildStrokePoints, clampDirtyRect } from './gpuUtils'
|
||||
|
||||
export function useGPUResources() {
|
||||
const store = useMaskEditorStore()
|
||||
|
||||
// GPU state — plain variables, not reactive, as Vue doesn't need to track them
|
||||
let maskTexture: GPUTexture | null = null
|
||||
let rgbTexture: GPUTexture | null = null
|
||||
let device: GPUDevice | null = null
|
||||
let renderer: GPUBrushRenderer | null = null
|
||||
let previewContext: GPUCanvasContext | null = null
|
||||
|
||||
// Readback buffers
|
||||
let readbackStorageMask: GPUBuffer | null = null
|
||||
let readbackStorageRgb: GPUBuffer | null = null
|
||||
let readbackStagingMask: GPUBuffer | null = null
|
||||
let readbackStagingRgb: GPUBuffer | null = null
|
||||
let currentBufferSize = 0
|
||||
|
||||
// Reactive state shared with useBrushDrawing
|
||||
const previewCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
const isSavingHistory = ref(false)
|
||||
const dirtyRect = ref<DirtyRect>(resetDirtyRect())
|
||||
|
||||
const hasRenderer = ref(false)
|
||||
|
||||
const isRecreatingTextures = ref(false)
|
||||
|
||||
// ── Watchers ────────────────────────────────────────────────────────────────
|
||||
|
||||
watch(
|
||||
() => store.clearTrigger,
|
||||
() => clearGPU()
|
||||
)
|
||||
|
||||
watch(
|
||||
() => store.canvasHistory.currentStateIndex,
|
||||
async () => {
|
||||
if (isSavingHistory.value) return
|
||||
await updateGPUFromCanvas()
|
||||
if (renderer && previewContext) renderer.clearPreview(previewContext)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => store.gpuTexturesNeedRecreation,
|
||||
async (needsRecreation) => {
|
||||
if (
|
||||
!needsRecreation ||
|
||||
!device ||
|
||||
!store.maskCanvas ||
|
||||
isRecreatingTextures.value
|
||||
)
|
||||
return
|
||||
|
||||
/* c8 ignore start */
|
||||
isRecreatingTextures.value = true
|
||||
|
||||
const width = store.gpuTextureWidth
|
||||
const height = store.gpuTextureHeight
|
||||
|
||||
try {
|
||||
maskTexture?.destroy()
|
||||
maskTexture = null
|
||||
rgbTexture?.destroy()
|
||||
rgbTexture = null
|
||||
|
||||
maskTexture = createTexture(device, width, height)
|
||||
rgbTexture = createTexture(device, width, height)
|
||||
|
||||
if (store.pendingGPUMaskData && store.pendingGPURgbData) {
|
||||
device.queue.writeTexture(
|
||||
{ texture: maskTexture },
|
||||
store.pendingGPUMaskData,
|
||||
{ bytesPerRow: width * 4 },
|
||||
{ width, height }
|
||||
)
|
||||
device.queue.writeTexture(
|
||||
{ texture: rgbTexture },
|
||||
store.pendingGPURgbData,
|
||||
{ bytesPerRow: width * 4 },
|
||||
{ width, height }
|
||||
)
|
||||
} else {
|
||||
await updateGPUFromCanvas()
|
||||
}
|
||||
|
||||
if (previewCanvas.value && renderer) {
|
||||
previewCanvas.value.width = width
|
||||
previewCanvas.value.height = height
|
||||
}
|
||||
|
||||
resizeReadbackBuffers(device, width, height)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[useGPUResources] Failed to recreate GPU textures:',
|
||||
error
|
||||
)
|
||||
} finally {
|
||||
store.gpuTexturesNeedRecreation = false
|
||||
store.gpuTextureWidth = 0
|
||||
store.gpuTextureHeight = 0
|
||||
store.pendingGPUMaskData = null
|
||||
store.pendingGPURgbData = null
|
||||
isRecreatingTextures.value = false
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
// c8 ignore start
|
||||
renderer?.destroy()
|
||||
renderer = null
|
||||
hasRenderer.value = false
|
||||
maskTexture?.destroy()
|
||||
maskTexture = null
|
||||
rgbTexture?.destroy()
|
||||
rgbTexture = null
|
||||
readbackStorageMask?.destroy()
|
||||
readbackStorageMask = null
|
||||
readbackStorageRgb?.destroy()
|
||||
readbackStorageRgb = null
|
||||
readbackStagingMask?.destroy()
|
||||
readbackStagingMask = null
|
||||
readbackStagingRgb?.destroy()
|
||||
readbackStagingRgb = null
|
||||
previewContext = null
|
||||
previewCanvas.value = null
|
||||
dirtyRect.value = resetDirtyRect()
|
||||
// Device is managed by TGPU root; do not destroy it here
|
||||
// c8 ignore stop
|
||||
})
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/* c8 ignore start — requires a live GPUDevice */
|
||||
function createTexture(
|
||||
gpuDevice: GPUDevice,
|
||||
width: number,
|
||||
height: number
|
||||
): GPUTexture {
|
||||
return gpuDevice.createTexture({
|
||||
size: [width, height],
|
||||
format: 'rgba8unorm',
|
||||
usage:
|
||||
GPUTextureUsage.TEXTURE_BINDING |
|
||||
GPUTextureUsage.STORAGE_BINDING |
|
||||
GPUTextureUsage.RENDER_ATTACHMENT |
|
||||
GPUTextureUsage.COPY_DST |
|
||||
GPUTextureUsage.COPY_SRC
|
||||
})
|
||||
}
|
||||
|
||||
function resizeReadbackBuffers(
|
||||
gpuDevice: GPUDevice,
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
const bufferSize = width * height * 4
|
||||
if (currentBufferSize === bufferSize) return
|
||||
|
||||
readbackStorageMask?.destroy()
|
||||
readbackStorageRgb?.destroy()
|
||||
readbackStagingMask?.destroy()
|
||||
readbackStagingRgb?.destroy()
|
||||
|
||||
readbackStorageMask = gpuDevice.createBuffer({
|
||||
size: bufferSize,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
|
||||
})
|
||||
readbackStorageRgb = gpuDevice.createBuffer({
|
||||
size: bufferSize,
|
||||
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
|
||||
})
|
||||
readbackStagingMask = gpuDevice.createBuffer({
|
||||
size: bufferSize,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
|
||||
})
|
||||
readbackStagingRgb = gpuDevice.createBuffer({
|
||||
size: bufferSize,
|
||||
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
|
||||
})
|
||||
currentBufferSize = bufferSize
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
// ── Internal functions ───────────────────────────────────────────────────────
|
||||
|
||||
async function initTypeGPU(): Promise<void> {
|
||||
if (store.tgpuRoot) {
|
||||
/* c8 ignore start */
|
||||
device = store.tgpuRoot.device
|
||||
return
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
try {
|
||||
/* c8 ignore start — requires functional WebGPU hardware */
|
||||
const root = await tgpu.init()
|
||||
store.tgpuRoot = root
|
||||
device = root.device
|
||||
console.warn('✅ TypeGPU initialized! Root:', root)
|
||||
console.warn('Device info:', root.device.limits)
|
||||
/* c8 ignore stop */
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.warn('Failed to initialize TypeGPU:', message)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateGPUFromCanvas(): Promise<void> {
|
||||
if (
|
||||
!device ||
|
||||
!maskTexture ||
|
||||
!rgbTexture ||
|
||||
!store.maskCanvas ||
|
||||
!store.maskCtx ||
|
||||
!store.rgbCtx
|
||||
)
|
||||
return
|
||||
|
||||
/* c8 ignore start — requires live GPU device and textures */
|
||||
const w = store.maskCanvas.width
|
||||
const h = store.maskCanvas.height
|
||||
|
||||
const maskData = store.maskCtx.getImageData(0, 0, w, h)
|
||||
premultiplyData(maskData.data)
|
||||
device.queue.writeTexture(
|
||||
{ texture: maskTexture },
|
||||
maskData.data,
|
||||
{ bytesPerRow: w * 4 },
|
||||
{ width: w, height: h }
|
||||
)
|
||||
|
||||
const rgbData = store.rgbCtx.getImageData(0, 0, w, h)
|
||||
premultiplyData(rgbData.data)
|
||||
device.queue.writeTexture(
|
||||
{ texture: rgbTexture },
|
||||
rgbData.data,
|
||||
{ bytesPerRow: w * 4 },
|
||||
{ width: w, height: h }
|
||||
)
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function initGPUResources(): Promise<void> {
|
||||
await initTypeGPU()
|
||||
|
||||
if (!store.tgpuRoot || !device) {
|
||||
console.warn('TypeGPU not initialized, skipping GPU resource setup')
|
||||
return
|
||||
}
|
||||
if (
|
||||
!store.maskCanvas ||
|
||||
!store.rgbCanvas ||
|
||||
!store.maskCtx ||
|
||||
!store.rgbCtx
|
||||
) {
|
||||
console.warn('Canvas contexts not ready, skipping GPU resource setup')
|
||||
return
|
||||
}
|
||||
|
||||
const w = store.maskCanvas.width
|
||||
const h = store.maskCanvas.height
|
||||
|
||||
/* c8 ignore start — requires functional WebGPU hardware */
|
||||
try {
|
||||
console.warn(`🎨 Initializing GPU resources for ${w}x${h} canvas`)
|
||||
maskTexture = createTexture(device, w, h)
|
||||
rgbTexture = createTexture(device, w, h)
|
||||
await updateGPUFromCanvas()
|
||||
console.warn('✅ GPU resources initialized successfully')
|
||||
renderer = new GPUBrushRenderer(
|
||||
device,
|
||||
navigator.gpu.getPreferredCanvasFormat()
|
||||
)
|
||||
hasRenderer.value = true
|
||||
console.warn('✅ Brush renderer initialized')
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize GPU resources:', error)
|
||||
maskTexture = null
|
||||
rgbTexture = null
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
|
||||
function initPreviewCanvas(canvas: HTMLCanvasElement): void {
|
||||
if (!device) return
|
||||
/* c8 ignore start — requires live GPUDevice and WebGPU canvas context */
|
||||
const ctx = canvas.getContext('webgpu')
|
||||
if (!ctx) return
|
||||
ctx.configure({
|
||||
device,
|
||||
format: navigator.gpu.getPreferredCanvasFormat(),
|
||||
alphaMode: 'premultiplied'
|
||||
})
|
||||
previewContext = ctx
|
||||
previewCanvas.value = canvas
|
||||
console.warn('✅ Preview Canvas Initialized')
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
|
||||
function clearGPU(): void {
|
||||
if (!device || !maskTexture || !rgbTexture || !store.maskCanvas) return
|
||||
/* c8 ignore start — requires live GPUDevice and textures */
|
||||
const w = store.maskCanvas.width
|
||||
const h = store.maskCanvas.height
|
||||
const zeros = new Uint8Array(w * h * 4)
|
||||
device.queue.writeTexture(
|
||||
{ texture: maskTexture },
|
||||
zeros,
|
||||
{ bytesPerRow: w * 4 },
|
||||
{ width: w, height: h }
|
||||
)
|
||||
device.queue.writeTexture(
|
||||
{ texture: rgbTexture },
|
||||
zeros,
|
||||
{ bytesPerRow: w * 4 },
|
||||
{ width: w, height: h }
|
||||
)
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
renderer?.destroy()
|
||||
maskTexture?.destroy()
|
||||
rgbTexture?.destroy()
|
||||
readbackStorageMask?.destroy()
|
||||
readbackStorageRgb?.destroy()
|
||||
readbackStagingMask?.destroy()
|
||||
readbackStagingRgb?.destroy()
|
||||
renderer = null
|
||||
hasRenderer.value = false
|
||||
maskTexture = null
|
||||
rgbTexture = null
|
||||
readbackStorageMask = null
|
||||
readbackStorageRgb = null
|
||||
readbackStagingMask = null
|
||||
readbackStagingRgb = null
|
||||
currentBufferSize = 0
|
||||
previewContext = null
|
||||
previewCanvas.value = null
|
||||
dirtyRect.value = resetDirtyRect()
|
||||
/* c8 ignore next — tgpuRoot only exists after successful GPU init */
|
||||
if (store.tgpuRoot) {
|
||||
store.tgpuRoot.destroy()
|
||||
store.tgpuRoot = null
|
||||
}
|
||||
device = null
|
||||
}
|
||||
|
||||
// ── Wrappers called by useBrushDrawing ──────────────────────────────────────
|
||||
|
||||
function prepareStroke(): void {
|
||||
if (!renderer || !store.maskCanvas) return
|
||||
/* c8 ignore next */
|
||||
renderer.prepareStroke(store.maskCanvas.width, store.maskCanvas.height)
|
||||
}
|
||||
|
||||
function clearPreview(): void {
|
||||
if (!renderer || !previewContext) return
|
||||
/* c8 ignore next */
|
||||
renderer.clearPreview(previewContext)
|
||||
}
|
||||
|
||||
function compositeStroke(isRgb: boolean, isErasing: boolean): void {
|
||||
if (!renderer || !maskTexture || !rgbTexture || !store.maskCanvas) return
|
||||
/* c8 ignore start — requires live renderer */
|
||||
const targetTex = isRgb ? rgbTexture : maskTexture
|
||||
const { size, hardness, opacity, type } = store.brushSettings
|
||||
const effectiveSize = getEffectiveBrushSize(size, hardness)
|
||||
const effectiveHardness = getEffectiveHardness(
|
||||
size,
|
||||
hardness,
|
||||
effectiveSize
|
||||
)
|
||||
const brushShape = type === BrushShape.Rect ? 1 : 0
|
||||
renderer.compositeStroke(targetTex.createView(), {
|
||||
opacity,
|
||||
color: [0, 0, 0],
|
||||
hardness: effectiveHardness,
|
||||
screenSize: [store.maskCanvas.width, store.maskCanvas.height],
|
||||
brushShape,
|
||||
isErasing
|
||||
})
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
|
||||
async function copyGpuToCanvas(): Promise<{
|
||||
maskData: ImageData
|
||||
rgbData: ImageData
|
||||
}> {
|
||||
if (
|
||||
!device ||
|
||||
!maskTexture ||
|
||||
!rgbTexture ||
|
||||
!store.maskCanvas ||
|
||||
!store.rgbCanvas ||
|
||||
!store.maskCtx ||
|
||||
!store.rgbCtx ||
|
||||
!renderer
|
||||
)
|
||||
throw new Error('GPU resources not ready')
|
||||
|
||||
/* c8 ignore start — requires live GPU device, textures and renderer */
|
||||
const width = store.maskCanvas.width
|
||||
const height = store.maskCanvas.height
|
||||
|
||||
resizeReadbackBuffers(device, width, height)
|
||||
|
||||
renderer.prepareReadback(maskTexture, readbackStorageMask!)
|
||||
renderer.prepareReadback(rgbTexture, readbackStorageRgb!)
|
||||
|
||||
const encoder = device.createCommandEncoder()
|
||||
encoder.copyBufferToBuffer(
|
||||
readbackStorageMask!,
|
||||
0,
|
||||
readbackStagingMask!,
|
||||
0,
|
||||
currentBufferSize
|
||||
)
|
||||
encoder.copyBufferToBuffer(
|
||||
readbackStorageRgb!,
|
||||
0,
|
||||
readbackStagingRgb!,
|
||||
0,
|
||||
currentBufferSize
|
||||
)
|
||||
device.queue.submit([encoder.finish()])
|
||||
|
||||
await Promise.all([
|
||||
readbackStagingMask!.mapAsync(GPUMapMode.READ),
|
||||
readbackStagingRgb!.mapAsync(GPUMapMode.READ)
|
||||
])
|
||||
|
||||
const maskDataArr = new Uint8ClampedArray(
|
||||
readbackStagingMask!.getMappedRange().slice(0)
|
||||
)
|
||||
const rgbDataArr = new Uint8ClampedArray(
|
||||
readbackStagingRgb!.getMappedRange().slice(0)
|
||||
)
|
||||
readbackStagingMask!.unmap()
|
||||
readbackStagingRgb!.unmap()
|
||||
|
||||
const maskImageData = new ImageData(maskDataArr, width, height)
|
||||
const rgbImageData = new ImageData(rgbDataArr, width, height)
|
||||
|
||||
const { dx, dy, dw, dh } = clampDirtyRect(dirtyRect.value, width, height)
|
||||
store.maskCtx.putImageData(maskImageData, 0, 0, dx, dy, dw, dh)
|
||||
store.rgbCtx.putImageData(rgbImageData, 0, 0, dx, dy, dw, dh)
|
||||
|
||||
return { maskData: maskImageData, rgbData: rgbImageData }
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
|
||||
function gpuRender(points: Point[], skipResampling = false): void {
|
||||
if (!renderer || !maskTexture || !rgbTexture) return
|
||||
|
||||
/* c8 ignore start — requires live renderer */
|
||||
const isRgb = store.activeLayer === 'rgb'
|
||||
const color = resolveColor(isRgb)
|
||||
const stepPercentage =
|
||||
Math.pow(100, store.brushSettings.stepSize / 100) / 100
|
||||
const gpuStepSize = Math.max(1.0, store.brushSettings.size * stepPercentage)
|
||||
const strokePoints = buildStrokePoints(points, skipResampling, gpuStepSize)
|
||||
|
||||
const { size, hardness } = store.brushSettings
|
||||
const effectiveSize = getEffectiveBrushSize(size, hardness)
|
||||
const effectiveHardness = getEffectiveHardness(
|
||||
size,
|
||||
hardness,
|
||||
effectiveSize
|
||||
)
|
||||
const brushShape = store.brushSettings.type === BrushShape.Rect ? 1 : 0
|
||||
|
||||
renderer.renderStrokeToAccumulator(strokePoints, {
|
||||
size: effectiveSize,
|
||||
opacity: 0.5,
|
||||
hardness: effectiveHardness,
|
||||
color,
|
||||
width: store.maskCanvas!.width,
|
||||
height: store.maskCanvas!.height,
|
||||
brushShape
|
||||
})
|
||||
|
||||
for (const p of strokePoints) {
|
||||
dirtyRect.value = updateDirtyRect(
|
||||
dirtyRect.value,
|
||||
p.x,
|
||||
p.y,
|
||||
effectiveSize
|
||||
)
|
||||
}
|
||||
|
||||
if (previewContext) {
|
||||
const isErasing =
|
||||
store.currentTool === 'eraser' ||
|
||||
store.maskCtx?.globalCompositeOperation === 'destination-out'
|
||||
const targetTex = isRgb ? rgbTexture : maskTexture
|
||||
renderer.blitToCanvas(
|
||||
previewContext,
|
||||
{
|
||||
opacity: store.brushSettings.opacity,
|
||||
color,
|
||||
hardness: effectiveHardness,
|
||||
screenSize: [store.maskCanvas!.width, store.maskCanvas!.height],
|
||||
brushShape,
|
||||
isErasing
|
||||
},
|
||||
targetTex ?? undefined
|
||||
)
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
|
||||
async function gpuDrawPoint(point: Point, opacity = 1): Promise<void> {
|
||||
if (!renderer) return
|
||||
|
||||
/* c8 ignore start — requires live renderer */
|
||||
const width = store.maskCanvas!.width
|
||||
const height = store.maskCanvas!.height
|
||||
const { size, hardness } = store.brushSettings
|
||||
const effectiveSize = getEffectiveBrushSize(size, hardness)
|
||||
const effectiveHardness = getEffectiveHardness(
|
||||
size,
|
||||
hardness,
|
||||
effectiveSize
|
||||
)
|
||||
const brushShape = store.brushSettings.type === BrushShape.Rect ? 1 : 0
|
||||
|
||||
dirtyRect.value = updateDirtyRect(
|
||||
dirtyRect.value,
|
||||
point.x,
|
||||
point.y,
|
||||
effectiveSize
|
||||
)
|
||||
|
||||
renderer.renderStrokeToAccumulator(
|
||||
[{ x: point.x, y: point.y, pressure: opacity }],
|
||||
{
|
||||
size: effectiveSize,
|
||||
opacity: 0.5,
|
||||
hardness: effectiveHardness,
|
||||
color: [1, 1, 1],
|
||||
width,
|
||||
height,
|
||||
brushShape
|
||||
}
|
||||
)
|
||||
|
||||
if (maskTexture && previewContext) {
|
||||
const isRgb = store.activeLayer === 'rgb'
|
||||
const isErasing =
|
||||
store.currentTool === 'eraser' ||
|
||||
store.maskCtx?.globalCompositeOperation === 'destination-out'
|
||||
renderer.blitToCanvas(
|
||||
previewContext,
|
||||
{
|
||||
opacity: store.brushSettings.opacity,
|
||||
color: resolveColor(isRgb),
|
||||
hardness: effectiveHardness,
|
||||
screenSize: [width, height],
|
||||
brushShape,
|
||||
isErasing
|
||||
},
|
||||
undefined
|
||||
)
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/* c8 ignore start — only reachable after successful GPU init */
|
||||
function resolveColor(isRgb: boolean): [number, number, number] {
|
||||
if (isRgb) {
|
||||
const c = parseToRgb(store.rgbColor)
|
||||
return [c.r / 255, c.g / 255, c.b / 255]
|
||||
}
|
||||
const c = store.maskColor as { r: number; g: number; b: number }
|
||||
return [c.r / 255, c.g / 255, c.b / 255]
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
return {
|
||||
// Lifecycle — spread into useBrushDrawing's public return
|
||||
initGPUResources,
|
||||
initPreviewCanvas,
|
||||
clearGPU,
|
||||
destroy,
|
||||
// Rendering — called internally by useBrushDrawing
|
||||
gpuRender,
|
||||
gpuDrawPoint,
|
||||
copyGpuToCanvas,
|
||||
// Renderer wrappers — called internally by useBrushDrawing
|
||||
prepareStroke,
|
||||
clearPreview,
|
||||
compositeStroke,
|
||||
// Shared reactive state
|
||||
hasRenderer,
|
||||
previewCanvas,
|
||||
isSavingHistory,
|
||||
dirtyRect
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import type { JobGroup } from '@/composables/queue/useJobList'
|
||||
|
||||
const DETAILS_SHOW_DELAY_MS = 200
|
||||
const DETAILS_HIDE_DELAY_MS = 150
|
||||
|
||||
interface UseJobDetailsHoverOptions<TActive> {
|
||||
getActiveId: (active: TActive) => string
|
||||
getDisplayedJobGroups: () => JobGroup[]
|
||||
onReset?: () => void
|
||||
}
|
||||
|
||||
export function useJobDetailsHover<TActive>({
|
||||
getActiveId,
|
||||
getDisplayedJobGroups,
|
||||
onReset
|
||||
}: UseJobDetailsHoverOptions<TActive>) {
|
||||
const activeDetails = ref<TActive | null>(null)
|
||||
const hideTimer = ref<number | null>(null)
|
||||
const hideTimerJobId = ref<string | null>(null)
|
||||
const showTimer = ref<number | null>(null)
|
||||
|
||||
function clearHideTimer() {
|
||||
if (hideTimer.value !== null) {
|
||||
clearTimeout(hideTimer.value)
|
||||
hideTimer.value = null
|
||||
}
|
||||
hideTimerJobId.value = null
|
||||
}
|
||||
|
||||
function clearShowTimer() {
|
||||
if (showTimer.value !== null) {
|
||||
clearTimeout(showTimer.value)
|
||||
showTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function clearHoverTimers() {
|
||||
clearHideTimer()
|
||||
clearShowTimer()
|
||||
}
|
||||
|
||||
function resetActiveDetails() {
|
||||
clearHoverTimers()
|
||||
activeDetails.value = null
|
||||
onReset?.()
|
||||
}
|
||||
|
||||
function hasDisplayedJob(jobId: string) {
|
||||
return getDisplayedJobGroups().some((group) =>
|
||||
group.items.some((item) => item.id === jobId)
|
||||
)
|
||||
}
|
||||
|
||||
function scheduleDetailsShow(nextActive: TActive, onShow?: () => void) {
|
||||
const nextActiveId = getActiveId(nextActive)
|
||||
clearShowTimer()
|
||||
showTimer.value = window.setTimeout(() => {
|
||||
showTimer.value = null
|
||||
if (!hasDisplayedJob(nextActiveId)) return
|
||||
|
||||
activeDetails.value = nextActive
|
||||
onShow?.()
|
||||
}, DETAILS_SHOW_DELAY_MS)
|
||||
}
|
||||
|
||||
function scheduleDetailsHide(jobId?: string, onHide?: () => void) {
|
||||
if (!jobId) return
|
||||
|
||||
clearShowTimer()
|
||||
if (hideTimerJobId.value && hideTimerJobId.value !== jobId) {
|
||||
return
|
||||
}
|
||||
|
||||
clearHideTimer()
|
||||
hideTimerJobId.value = jobId
|
||||
hideTimer.value = window.setTimeout(() => {
|
||||
const currentActive = activeDetails.value
|
||||
if (currentActive && getActiveId(currentActive) === jobId) {
|
||||
activeDetails.value = null
|
||||
onHide?.()
|
||||
}
|
||||
hideTimer.value = null
|
||||
hideTimerJobId.value = null
|
||||
}, DETAILS_HIDE_DELAY_MS)
|
||||
}
|
||||
|
||||
watch(getDisplayedJobGroups, () => {
|
||||
const currentActive = activeDetails.value
|
||||
if (!currentActive) return
|
||||
|
||||
if (!hasDisplayedJob(getActiveId(currentActive))) {
|
||||
resetActiveDetails()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(resetActiveDetails)
|
||||
|
||||
return {
|
||||
activeDetails,
|
||||
clearHoverTimers,
|
||||
resetActiveDetails,
|
||||
scheduleDetailsHide,
|
||||
scheduleDetailsShow
|
||||
}
|
||||
}
|
||||
@@ -481,6 +481,30 @@ describe('useImageCrop', () => {
|
||||
expect(vm.modelValue.x).toBe(50)
|
||||
})
|
||||
|
||||
it('resizes from the top edge, moving y and shrinking height', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 500, 500)
|
||||
vm.modelValue = { x: 50, y: 100, width: 120, height: 200 }
|
||||
|
||||
const captureEl = document.createElement('div')
|
||||
captureEl.setPointerCapture = vi.fn()
|
||||
captureEl.releasePointerCapture = vi.fn()
|
||||
|
||||
const resizeStart = vm.handleResizeStart as (
|
||||
e: PointerEvent,
|
||||
dir: string
|
||||
) => void
|
||||
const resizeMove = vm.handleResizeMove as (e: PointerEvent) => void
|
||||
const resizeEnd = vm.handleResizeEnd as (e: PointerEvent) => void
|
||||
|
||||
resizeStart(makePointerEvent('pointerdown', captureEl, 100, 100), 'top')
|
||||
resizeMove(makePointerEvent('pointermove', captureEl, 100, 150))
|
||||
resizeEnd(makePointerEvent('pointerup', captureEl, 100, 150))
|
||||
|
||||
expect(vm.modelValue.y).toBeGreaterThan(100)
|
||||
expect(vm.modelValue.height).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('applies a preset aspect ratio and clamps height to the image', async () => {
|
||||
const vm = await mountHarness()
|
||||
setupImageLayout(vm, 800, 500)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref, shallowRef } from 'vue'
|
||||
import { nextTick, reactive, ref, shallowRef } from 'vue'
|
||||
import type { Pinia } from 'pinia'
|
||||
import { getActivePinia } from 'pinia'
|
||||
|
||||
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
@@ -9,6 +11,7 @@ import type { Size } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraph } from '@/lib/litegraph/src/LGraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import {
|
||||
@@ -59,6 +62,18 @@ vi.mock('@/i18n', () => ({
|
||||
t: vi.fn((key) => key)
|
||||
}))
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
getActivePinia: vi.fn(() => null)
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
|
||||
describe('useLoad3d', () => {
|
||||
let mockLoad3d: Partial<Load3d>
|
||||
let mockNode: LGraphNode
|
||||
@@ -67,6 +82,7 @@ describe('useLoad3d', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)
|
||||
|
||||
mockNode = createMockLGraphNode({
|
||||
properties: {
|
||||
@@ -334,6 +350,73 @@ describe('useLoad3d', () => {
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
|
||||
})
|
||||
|
||||
it('passes getZoomScale callback to createLoad3d', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(createLoad3d).toHaveBeenCalledWith(
|
||||
containerRef,
|
||||
expect.objectContaining({ getZoomScale: expect.any(Function) })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zoom watcher', () => {
|
||||
it('calls load3d.handleResize after debounce when canvas appScalePercentage changes', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const canvasStore = reactive({ appScalePercentage: 100 })
|
||||
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
|
||||
vi.mocked(useCanvasStore).mockReturnValue(
|
||||
canvasStore as unknown as ReturnType<typeof useCanvasStore>
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
vi.mocked(mockLoad3d.handleResize!).mockClear()
|
||||
|
||||
canvasStore.appScalePercentage = 200
|
||||
await nextTick()
|
||||
expect(mockLoad3d.handleResize).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(mockLoad3d.handleResize).toHaveBeenCalledOnce()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('debounces rapid zoom changes into a single handleResize call', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const canvasStore = reactive({ appScalePercentage: 100 })
|
||||
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
|
||||
vi.mocked(useCanvasStore).mockReturnValue(
|
||||
canvasStore as unknown as ReturnType<typeof useCanvasStore>
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
vi.mocked(mockLoad3d.handleResize!).mockClear()
|
||||
|
||||
canvasStore.appScalePercentage = 150
|
||||
await nextTick()
|
||||
canvasStore.appScalePercentage = 200
|
||||
await nextTick()
|
||||
canvasStore.appScalePercentage = 250
|
||||
await nextTick()
|
||||
|
||||
vi.advanceTimersByTime(150)
|
||||
expect(mockLoad3d.handleResize).toHaveBeenCalledOnce()
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
describe('preserves existing node callbacks through initializeLoad3d', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
import { toRef } from '@vueuse/core'
|
||||
import { toRef, useDebounceFn } from '@vueuse/core'
|
||||
import { getActivePinia } from 'pinia'
|
||||
import { ref, toRaw, watch } from 'vue'
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
@@ -44,6 +45,15 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
let load3d: Load3d | null = null
|
||||
let isFirstModelLoad = true
|
||||
|
||||
const debouncedHandleResize = useDebounceFn(() => {
|
||||
load3d?.handleResize()
|
||||
}, 150)
|
||||
|
||||
watch(
|
||||
() => (getActivePinia() ? useCanvasStore().appScalePercentage : 0),
|
||||
debouncedHandleResize
|
||||
)
|
||||
|
||||
const sceneConfig = ref<SceneConfig>({
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
@@ -132,6 +142,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
height: heightWidget.value as number
|
||||
})
|
||||
: undefined,
|
||||
getZoomScale: () => app.canvas?.ds?.scale ?? 1,
|
||||
onContextMenu: (event) => {
|
||||
const menuOptions = app.canvas.getNodeMenuOptions(node)
|
||||
new LiteGraph.ContextMenu(menuOptions, {
|
||||
|
||||
@@ -404,6 +404,23 @@ describe('useLoad3dViewer', () => {
|
||||
.intensity
|
||||
).toBe(1)
|
||||
})
|
||||
|
||||
it('should preserve unknown fields on Model Config when restoring', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
;(
|
||||
mockNode.properties!['Model Config'] as Record<string, unknown>
|
||||
).futureField = 'preserve-me'
|
||||
|
||||
viewer.restoreInitialState()
|
||||
|
||||
expect(
|
||||
(mockNode.properties!['Model Config'] as Record<string, unknown>)
|
||||
.futureField
|
||||
).toBe('preserve-me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyChanges', () => {
|
||||
@@ -457,6 +474,23 @@ describe('useLoad3dViewer', () => {
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should preserve unknown fields on Model Config when applying', async () => {
|
||||
const viewer = useLoad3dViewer(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await viewer.initializeViewer(containerRef, mockSourceLoad3d as Load3d)
|
||||
;(
|
||||
mockNode.properties!['Model Config'] as Record<string, unknown>
|
||||
).futureField = 'preserve-me'
|
||||
|
||||
await viewer.applyChanges()
|
||||
|
||||
expect(
|
||||
(mockNode.properties!['Model Config'] as Record<string, unknown>)
|
||||
.futureField
|
||||
).toBe('preserve-me')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshViewport', () => {
|
||||
|
||||
@@ -619,7 +619,11 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
intensity: initialState.value.lightIntensity
|
||||
}
|
||||
|
||||
const existingModelConfig = nodeValue.properties['Model Config'] as
|
||||
| ModelConfig
|
||||
| undefined
|
||||
nodeValue.properties['Model Config'] = {
|
||||
...existingModelConfig,
|
||||
upDirection: initialState.value.upDirection,
|
||||
materialMode: initialState.value.materialMode,
|
||||
gizmo: {
|
||||
@@ -671,10 +675,13 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
}
|
||||
|
||||
const gizmoTransform = load3d.getGizmoTransform()
|
||||
const existingModelConfig = nodeValue.properties['Model Config'] as
|
||||
| ModelConfig
|
||||
| undefined
|
||||
nodeValue.properties['Model Config'] = {
|
||||
...existingModelConfig,
|
||||
upDirection: upDirection.value,
|
||||
materialMode: materialMode.value,
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled: gizmoEnabled.value,
|
||||
mode: gizmoMode.value,
|
||||
|
||||
124
src/extensions/core/editAttention.test.ts
Normal file
124
src/extensions/core/editAttention.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
registerExtension: vi.fn(),
|
||||
ui: { settings: { addSetting: vi.fn() } }
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
addWeightToParentheses,
|
||||
findNearestEnclosure,
|
||||
incrementWeight
|
||||
} from './editAttention'
|
||||
|
||||
describe('incrementWeight', () => {
|
||||
it('increments a weight by the given delta', () => {
|
||||
expect(incrementWeight('1.0', 0.05)).toBe('1.05')
|
||||
})
|
||||
|
||||
it('decrements a weight by the given delta', () => {
|
||||
expect(incrementWeight('1.05', -0.05)).toBe('1')
|
||||
})
|
||||
|
||||
it('returns the original string when weight is not a number', () => {
|
||||
expect(incrementWeight('abc', 0.05)).toBe('abc')
|
||||
})
|
||||
|
||||
it('rounds correctly and avoids floating point accumulation', () => {
|
||||
expect(incrementWeight('1.1', 0.1)).toBe('1.2')
|
||||
})
|
||||
|
||||
it('can produce a weight of zero', () => {
|
||||
expect(incrementWeight('0.05', -0.05)).toBe('0')
|
||||
})
|
||||
|
||||
it('produces negative weights', () => {
|
||||
expect(incrementWeight('0.0', -0.05)).toBe('-0.05')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findNearestEnclosure', () => {
|
||||
it.each([
|
||||
[
|
||||
'returns start and end of a simple parenthesized expression',
|
||||
'(cat)',
|
||||
2,
|
||||
{ start: 1, end: 4 }
|
||||
],
|
||||
[
|
||||
'finds enclosure when cursor is on opening paren',
|
||||
'(cat)',
|
||||
0,
|
||||
{ start: 1, end: 4 }
|
||||
],
|
||||
['returns null when there are no parentheses', 'cat dog', 3, null],
|
||||
['returns null when cursor is outside any enclosure', '(cat) dog', 7, null],
|
||||
[
|
||||
'finds the inner enclosure when cursor is on nested content',
|
||||
'(outer (inner) end)',
|
||||
9,
|
||||
{ start: 8, end: 13 }
|
||||
],
|
||||
[
|
||||
'finds the outer enclosure when cursor is on outer content',
|
||||
'(outer (inner) end)',
|
||||
2,
|
||||
{ start: 1, end: 18 }
|
||||
],
|
||||
['returns null for empty string', '', 0, null],
|
||||
[
|
||||
'returns null when opening paren has no matching closing paren',
|
||||
'(cat',
|
||||
2,
|
||||
null
|
||||
]
|
||||
])('%s', (_, text, cursor, expected) => {
|
||||
expect(findNearestEnclosure(text, cursor)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addWeightToParentheses', () => {
|
||||
it.each([
|
||||
['adds weight 1.0 to a bare parenthesized token', '(cat)', '(cat:1.0)'],
|
||||
[
|
||||
'leaves a token that already has a weight unchanged',
|
||||
'(cat:1.5)',
|
||||
'(cat:1.5)'
|
||||
],
|
||||
['leaves a token without parentheses unchanged', 'cat', 'cat'],
|
||||
[
|
||||
'leaves a token with scientific notation weight unchanged',
|
||||
'(cat:1e-3)',
|
||||
'(cat:1e-3)'
|
||||
],
|
||||
[
|
||||
'leaves a token with a negative weight unchanged',
|
||||
'(cat:-0.5)',
|
||||
'(cat:-0.5)'
|
||||
],
|
||||
[
|
||||
'adds weight to a multi-word parenthesized token',
|
||||
'(cat dog)',
|
||||
'(cat dog:1.0)'
|
||||
],
|
||||
[
|
||||
'adds weight when colon-number appears in content but no trailing weight exists',
|
||||
'(time 12:30)',
|
||||
'(time 12:30:1.0)'
|
||||
],
|
||||
[
|
||||
'preserves existing weight on a digit-ending token name',
|
||||
'(v2:1.5)',
|
||||
'(v2:1.5)'
|
||||
],
|
||||
[
|
||||
'preserves existing weight on a LoRA name ending in a digit',
|
||||
'(sdxl1:0.8)',
|
||||
'(sdxl1:0.8)'
|
||||
]
|
||||
])('%s', (_, input, expected) => {
|
||||
expect(addWeightToParentheses(input)).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,62 @@
|
||||
import { app } from '../../scripts/app'
|
||||
|
||||
// Allows you to edit the attention weight by holding ctrl (or cmd) and using the up/down arrow keys
|
||||
type Enclosure = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export function incrementWeight(weight: string, delta: number): string {
|
||||
const floatWeight = parseFloat(weight)
|
||||
if (isNaN(floatWeight)) return weight
|
||||
const newWeight = floatWeight + delta
|
||||
return String(Number(newWeight.toFixed(10)))
|
||||
}
|
||||
|
||||
export function findNearestEnclosure(
|
||||
text: string,
|
||||
cursorPos: number
|
||||
): Enclosure | null {
|
||||
let start = cursorPos
|
||||
let end = cursorPos
|
||||
let openCount = 0
|
||||
let closeCount = 0
|
||||
|
||||
if (text[cursorPos] === '(') {
|
||||
end = cursorPos + 1
|
||||
} else {
|
||||
while (start >= 0) {
|
||||
start--
|
||||
if (text[start] === '(' && openCount === closeCount) break
|
||||
if (text[start] === '(') openCount++
|
||||
if (text[start] === ')') closeCount++
|
||||
}
|
||||
if (start < 0) return null
|
||||
openCount = 0
|
||||
closeCount = 0
|
||||
}
|
||||
|
||||
while (end < text.length) {
|
||||
if (text[end] === ')' && openCount === closeCount) break
|
||||
if (text[end] === '(') openCount++
|
||||
if (text[end] === ')') closeCount++
|
||||
end++
|
||||
}
|
||||
if (end === text.length) return null
|
||||
|
||||
return { start: start + 1, end: end }
|
||||
}
|
||||
|
||||
export function addWeightToParentheses(text: string): string {
|
||||
const parenMatch = text.match(/^\((.*)\)$/)
|
||||
if (!parenMatch) return text
|
||||
const innerText = parenMatch[1]
|
||||
// A time-like pattern (e.g. "12:30") is preceded by whitespace or string-start;
|
||||
// everything else ending in ":number" is a weight, including digit-ending names like "v2:1.5".
|
||||
const looksLikeTime = /(?:^|\s)\d{1,2}:\d{2}$/.test(innerText)
|
||||
const hasTrailingWeight =
|
||||
!looksLikeTime && /:[+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?$/.test(innerText)
|
||||
return hasTrailingWeight ? text : `(${innerText}:1.0)`
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.EditAttention',
|
||||
@@ -18,65 +74,6 @@ app.registerExtension({
|
||||
defaultValue: 0.05
|
||||
})
|
||||
|
||||
function incrementWeight(weight: string, delta: number): string {
|
||||
const floatWeight = parseFloat(weight)
|
||||
if (isNaN(floatWeight)) return weight
|
||||
const newWeight = floatWeight + delta
|
||||
return String(Number(newWeight.toFixed(10)))
|
||||
}
|
||||
|
||||
type Enclosure = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
function findNearestEnclosure(
|
||||
text: string,
|
||||
cursorPos: number
|
||||
): Enclosure | null {
|
||||
let start = cursorPos,
|
||||
end = cursorPos
|
||||
let openCount = 0,
|
||||
closeCount = 0
|
||||
|
||||
// Find opening parenthesis before cursor
|
||||
while (start >= 0) {
|
||||
start--
|
||||
if (text[start] === '(' && openCount === closeCount) break
|
||||
if (text[start] === '(') openCount++
|
||||
if (text[start] === ')') closeCount++
|
||||
}
|
||||
if (start < 0) return null
|
||||
|
||||
openCount = 0
|
||||
closeCount = 0
|
||||
|
||||
// Find closing parenthesis after cursor
|
||||
while (end < text.length) {
|
||||
if (text[end] === ')' && openCount === closeCount) break
|
||||
if (text[end] === '(') openCount++
|
||||
if (text[end] === ')') closeCount++
|
||||
end++
|
||||
}
|
||||
if (end === text.length) return null
|
||||
|
||||
return { start: start + 1, end: end }
|
||||
}
|
||||
|
||||
function addWeightToParentheses(text: string): string {
|
||||
const parenRegex = /^\((.*)\)$/
|
||||
const parenMatch = text.match(parenRegex)
|
||||
|
||||
const floatRegex = /:([+-]?(\d*\.)?\d+([eE][+-]?\d+)?)/
|
||||
const floatMatch = text.match(floatRegex)
|
||||
|
||||
if (parenMatch && !floatMatch) {
|
||||
return `(${parenMatch[1]}:1.0)`
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function editAttention(event: KeyboardEvent) {
|
||||
// @ts-expect-error Runtime narrowing not impl.
|
||||
const inputField: HTMLTextAreaElement = event.composedPath()[0]
|
||||
@@ -92,7 +89,6 @@ app.registerExtension({
|
||||
let end = inputField.selectionEnd
|
||||
let selectedText = inputField.value.substring(start, end)
|
||||
|
||||
// If there is no selection, attempt to find the nearest enclosure, or select the current word
|
||||
if (!selectedText) {
|
||||
const nearestEnclosure = findNearestEnclosure(inputField.value, start)
|
||||
if (nearestEnclosure) {
|
||||
@@ -100,7 +96,6 @@ app.registerExtension({
|
||||
end = nearestEnclosure.end
|
||||
selectedText = inputField.value.substring(start, end)
|
||||
} else {
|
||||
// Select the current word, find the start and end of the word
|
||||
const delimiters = ' .,\\/!?%^*;:{}=-_`~()\r\n\t'
|
||||
|
||||
while (
|
||||
@@ -122,37 +117,30 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
// If the selection ends with a space, remove it
|
||||
if (selectedText[selectedText.length - 1] === ' ') {
|
||||
const selectionEndsWithSpace =
|
||||
selectedText[selectedText.length - 1] === ' '
|
||||
if (selectionEndsWithSpace) {
|
||||
selectedText = selectedText.substring(0, selectedText.length - 1)
|
||||
end -= 1
|
||||
}
|
||||
|
||||
// If there are parentheses left and right of the selection, select them
|
||||
if (
|
||||
inputField.value[start - 1] === '(' &&
|
||||
inputField.value[end] === ')'
|
||||
) {
|
||||
const selectionIsSurroundedByParens =
|
||||
inputField.value[start - 1] === '(' && inputField.value[end] === ')'
|
||||
if (selectionIsSurroundedByParens) {
|
||||
start -= 1
|
||||
end += 1
|
||||
selectedText = inputField.value.substring(start, end)
|
||||
}
|
||||
|
||||
// If the selection is not enclosed in parentheses, add them
|
||||
if (
|
||||
selectedText[0] !== '(' ||
|
||||
selectedText[selectedText.length - 1] !== ')'
|
||||
) {
|
||||
selectedText = `(${selectedText})`
|
||||
}
|
||||
const selectionIsNotEnclosedInParens =
|
||||
selectedText[0] !== '(' || selectedText[selectedText.length - 1] !== ')'
|
||||
if (selectionIsNotEnclosedInParens) selectedText = `(${selectedText})`
|
||||
|
||||
// If the selection does not have a weight, add a weight of 1.0
|
||||
selectedText = addWeightToParentheses(selectedText)
|
||||
|
||||
// Increment the weight
|
||||
const weightDelta = event.key === 'ArrowUp' ? delta : -delta
|
||||
const updatedText = selectedText.replace(
|
||||
/\((.*):([+-]?\d+(?:\.\d+)?)\)/,
|
||||
/\((.*):([+-]?(?:\d*\.)?\d+(?:[eE][+-]?\d+)?)\)/,
|
||||
(_, text, weight) => {
|
||||
weight = incrementWeight(weight, weightDelta)
|
||||
if (weight == 1) {
|
||||
|
||||
487
src/extensions/core/load3d.test.ts
Normal file
487
src/extensions/core/load3d.test.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const {
|
||||
registerExtensionMock,
|
||||
waitForLoad3dMock,
|
||||
configureMock,
|
||||
getLoad3dMock,
|
||||
toastAddAlertMock
|
||||
} = vi.hoisted(() => ({
|
||||
registerExtensionMock: vi.fn(),
|
||||
waitForLoad3dMock: vi.fn(),
|
||||
configureMock: vi.fn(),
|
||||
getLoad3dMock: vi.fn(),
|
||||
toastAddAlertMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({ registerExtension: registerExtensionMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/load3dService', () => ({
|
||||
useLoad3dService: () => ({
|
||||
getLoad3d: getLoad3dMock,
|
||||
handleViewerClose: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
useLoad3d: () => ({ waitForLoad3d: waitForLoad3dMock }),
|
||||
nodeToLoad3dMap: new Map()
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
|
||||
default: class {
|
||||
configure = configureMock
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/exportMenuHelper', () => ({
|
||||
createExportMenuItems: vi.fn(() => [{ content: 'Export' }])
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
default: {
|
||||
splitFilePath: vi.fn((p: string) => ['', p]),
|
||||
getResourceURL: vi.fn(() => '/view'),
|
||||
uploadFile: vi.fn(),
|
||||
uploadMultipleFiles: vi.fn(),
|
||||
uploadTempImage: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/constants', () => ({
|
||||
SUPPORTED_EXTENSIONS_ACCEPT: '.glb,.gltf'
|
||||
}))
|
||||
|
||||
vi.mock('@/components/load3d/Load3D.vue', () => ({ default: {} }))
|
||||
vi.mock('@/components/load3d/Load3dViewerContent.vue', () => ({ default: {} }))
|
||||
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
ComponentWidgetImpl: vi.fn(),
|
||||
addWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: { apiURL: (p: string) => p }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { selected_nodes: {} } },
|
||||
ComfyApp: { copyToClipspace: vi.fn(), clipspace_return_node: null }
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ addAlert: toastAddAlertMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog: vi.fn() })
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLoad3dNode: vi.fn(() => true)
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', () => ({
|
||||
LiteGraph: { ContextMenu: vi.fn() }
|
||||
}))
|
||||
|
||||
type ExtCreated = ComfyExtension & {
|
||||
nodeCreated: (node: LGraphNode) => Promise<void>
|
||||
beforeRegisterNodeDef: (
|
||||
nodeType: typeof LGraphNode,
|
||||
nodeData: ComfyNodeDef
|
||||
) => Promise<void>
|
||||
getNodeMenuItems: (node: LGraphNode) => unknown[]
|
||||
}
|
||||
|
||||
async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
return {
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated
|
||||
}
|
||||
}
|
||||
|
||||
interface FakeWidget {
|
||||
name: string
|
||||
value: unknown
|
||||
serializeValue?: () => Promise<unknown>
|
||||
}
|
||||
|
||||
function makePreview3DNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3D' },
|
||||
size: [400, 550],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [{ name: 'model_file', value: '' }],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function makeLoad3DNode(
|
||||
overrides: Partial<{
|
||||
comfyClass: string
|
||||
properties: Record<string, unknown>
|
||||
widgets: FakeWidget[]
|
||||
}> = {}
|
||||
): LGraphNode {
|
||||
return {
|
||||
constructor: { comfyClass: overrides.comfyClass ?? 'Load3D' },
|
||||
size: [300, 600],
|
||||
setSize: vi.fn(),
|
||||
widgets: overrides.widgets ?? [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'image', value: '' }
|
||||
],
|
||||
properties: overrides.properties ?? {}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
interface FakeLoad3d {
|
||||
whenLoadIdle: () => Promise<void>
|
||||
setCameraFromMatrices: ReturnType<typeof vi.fn>
|
||||
setBackgroundImage: ReturnType<typeof vi.fn>
|
||||
isSplatModel: ReturnType<typeof vi.fn>
|
||||
currentLoadGeneration: number
|
||||
}
|
||||
|
||||
function makeLoad3dMock(): FakeLoad3d {
|
||||
return {
|
||||
whenLoadIdle: vi.fn().mockResolvedValue(undefined),
|
||||
setCameraFromMatrices: vi.fn(),
|
||||
setBackgroundImage: vi.fn(),
|
||||
isSplatModel: vi.fn(() => false),
|
||||
currentLoadGeneration: 0
|
||||
}
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function setupBaseMocks() {
|
||||
vi.clearAllMocks()
|
||||
waitForLoad3dMock.mockImplementation((cb: (load3d: FakeLoad3d) => void) => {
|
||||
cb(makeLoad3dMock())
|
||||
})
|
||||
}
|
||||
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D and Comfy.Preview3D extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt } = await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(2)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3D.beforeRegisterNodeDef', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('rewrites the image input spec for Preview3D nodes', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const nodeData = {
|
||||
name: 'Preview3D',
|
||||
input: { required: { image: ['STRING', {}] } }
|
||||
} as unknown as ComfyNodeDef
|
||||
|
||||
await preview3DExt.beforeRegisterNodeDef({} as typeof LGraphNode, nodeData)
|
||||
|
||||
expect(nodeData.input!.required!.image).toEqual(['PREVIEW_3D'])
|
||||
})
|
||||
|
||||
it('leaves non-Preview3D node defs unchanged', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const nodeData = {
|
||||
name: 'Load3D',
|
||||
input: { required: { image: ['STRING', {}] } }
|
||||
} as unknown as ComfyNodeDef
|
||||
|
||||
await preview3DExt.beforeRegisterNodeDef({} as typeof LGraphNode, nodeData)
|
||||
|
||||
expect(nodeData.input!.required!.image).toEqual(['STRING', {}])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3D.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Preview3D', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not configure on creation when no Last Time Model File is persisted', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores via configure with persisted cameraState when Last Time Model File is set', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const cameraState = { position: [1, 2, 3] }
|
||||
const node = makePreview3DNode({
|
||||
properties: {
|
||||
'Last Time Model File': 'prev/model.glb',
|
||||
'Camera Config': { cameraType: 'perspective', state: cameraState }
|
||||
}
|
||||
})
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).toHaveBeenCalledWith({
|
||||
loadFolder: 'output',
|
||||
modelWidget: expect.objectContaining({ value: 'prev/model.glb' }),
|
||||
cameraState,
|
||||
silentOnNotFound: true
|
||||
})
|
||||
})
|
||||
|
||||
it('persists Last Time Model File and normalizes backslashes after onExecuted', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['sub\\nested\\mesh.glb'] })
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('sub/nested/mesh.glb')
|
||||
expect(configureMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
loadFolder: 'output',
|
||||
silentOnNotFound: true
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards bgImagePath to load3d.setBackgroundImage on execute', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['mesh.glb', undefined, 'bg.png'] })
|
||||
|
||||
expect(load3d.setBackgroundImage).toHaveBeenCalledWith('bg.png')
|
||||
})
|
||||
|
||||
it('applies camera matrices when load3d generation is unchanged', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
const extrinsics = [
|
||||
[1, 0, 0, 0],
|
||||
[0, 1, 0, 0],
|
||||
[0, 0, 1, 0],
|
||||
[0, 0, 0, 1]
|
||||
]
|
||||
const intrinsics = [
|
||||
[1, 0, 0],
|
||||
[0, 1, 0],
|
||||
[0, 0, 1]
|
||||
]
|
||||
|
||||
const node = makePreview3DNode()
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, undefined, extrinsics, intrinsics]
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraFromMatrices).toHaveBeenCalledWith(
|
||||
extrinsics,
|
||||
intrinsics
|
||||
)
|
||||
})
|
||||
|
||||
it('skips camera matrix application when load3d generation changes before whenLoadIdle resolves', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const load3d = makeLoad3dMock()
|
||||
load3d.currentLoadGeneration = 5
|
||||
let resolveIdle: () => void = () => {}
|
||||
load3d.whenLoadIdle = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveIdle = resolve
|
||||
})
|
||||
)
|
||||
waitForLoad3dMock.mockImplementation((cb: (l: FakeLoad3d) => void) =>
|
||||
cb(load3d)
|
||||
)
|
||||
|
||||
const node = makePreview3DNode()
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({
|
||||
result: ['mesh.glb', undefined, undefined, [[1]], [[1]]]
|
||||
})
|
||||
|
||||
load3d.currentLoadGeneration = 6
|
||||
resolveIdle()
|
||||
await flush()
|
||||
|
||||
expect(load3d.setCameraFromMatrices).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows an error toast when onExecuted has no file path', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DNode()
|
||||
|
||||
await preview3DExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: [] })
|
||||
|
||||
expect(toastAddAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.unableToGetModelFilePath'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Load3D', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = makeLoad3DNode({ comfyClass: 'OtherNode' })
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('configures with the input folder and width/height widgets', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [
|
||||
{ name: 'model_file', value: 'model.glb' },
|
||||
{ name: 'width', value: 1024 },
|
||||
{ name: 'height', value: 768 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
const node = makeLoad3DNode({ widgets })
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
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 () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const widgets: FakeWidget[] = [
|
||||
{ name: 'model_file', value: '' },
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'image', value: '' }
|
||||
]
|
||||
const node = makeLoad3DNode({ widgets })
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(typeof widgets[3].serializeValue).toBe('function')
|
||||
})
|
||||
|
||||
it('skips configure when required widgets are missing', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = makeLoad3DNode({
|
||||
widgets: [{ name: 'model_file', value: '' }]
|
||||
})
|
||||
|
||||
await load3DExt.nodeCreated(node)
|
||||
|
||||
expect(configureMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeMenuItems', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('Comfy.Load3D returns [] for non-Load3D nodes', async () => {
|
||||
const { load3DExt } = await loadExtensionsFresh()
|
||||
const node = {
|
||||
constructor: { comfyClass: 'OtherNode' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(load3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('Comfy.Preview3D returns [] for non-Preview3D nodes', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
const node = {
|
||||
constructor: { comfyClass: 'OtherNode' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] when no load3d instance exists for the node', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue(null)
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3D' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] for splat models', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => true })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3D' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns export menu items for non-splat 3D nodes', async () => {
|
||||
const { preview3DExt } = await loadExtensionsFresh()
|
||||
getLoad3dMock.mockReturnValue({ isSplatModel: () => false })
|
||||
const node = {
|
||||
constructor: { comfyClass: 'Preview3D' }
|
||||
} as unknown as LGraphNode
|
||||
|
||||
expect(preview3DExt.getNodeMenuItems(node)).toEqual([{ content: 'Export' }])
|
||||
})
|
||||
})
|
||||
@@ -1,20 +1,27 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import Load3DConfiguration, {
|
||||
parseAnnotatedFilename
|
||||
} from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import type {
|
||||
CameraConfig,
|
||||
GizmoConfig,
|
||||
ModelConfig
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { Dictionary } from '@/lib/litegraph/src/interfaces'
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const { settingsGetMock } = vi.hoisted(() => ({
|
||||
settingsGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({
|
||||
get: vi.fn()
|
||||
})
|
||||
useSettingStore: () => ({ get: settingsGetMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
@@ -41,13 +48,22 @@ vi.mock('@/extensions/core/load3d/Load3dUtils', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
type WithPrivate = { loadModelConfig(): ModelConfig }
|
||||
type WithPrivate = {
|
||||
loadModelConfig(): ModelConfig
|
||||
loadSceneConfig(): SceneConfig
|
||||
loadCameraConfig(): CameraConfig
|
||||
loadLightConfig(): LightConfig
|
||||
}
|
||||
|
||||
function createConfig(properties?: Dictionary<NodeProperty | undefined>) {
|
||||
const load3d = {} as Load3d
|
||||
return new Load3DConfiguration(load3d, properties) as unknown as WithPrivate
|
||||
}
|
||||
|
||||
function stubSettings(values: Record<string, unknown>) {
|
||||
settingsGetMock.mockImplementation((key: string) => values[key])
|
||||
}
|
||||
|
||||
const defaultGizmo: GizmoConfig = {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
@@ -56,6 +72,13 @@ const defaultGizmo: GizmoConfig = {
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
|
||||
const hdriDefaults = {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
} as const
|
||||
|
||||
describe('Load3DConfiguration.loadModelConfig', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
@@ -249,3 +272,277 @@ describe('Load3DConfiguration.silentOnNotFound propagation', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseAnnotatedFilename', () => {
|
||||
it('strips a [output] suffix and switches to the output folder', () => {
|
||||
expect(parseAnnotatedFilename('foo.glb [output]', 'input')).toEqual({
|
||||
filename: 'foo.glb',
|
||||
folder: 'output'
|
||||
})
|
||||
})
|
||||
|
||||
it('strips a [input] suffix and switches to the input folder', () => {
|
||||
expect(parseAnnotatedFilename('sub/foo.glb [input]', 'output')).toEqual({
|
||||
filename: 'sub/foo.glb',
|
||||
folder: 'input'
|
||||
})
|
||||
})
|
||||
|
||||
it('strips a [temp] suffix and switches to the temp folder', () => {
|
||||
expect(parseAnnotatedFilename('foo.glb [temp]', 'input')).toEqual({
|
||||
filename: 'foo.glb',
|
||||
folder: 'temp'
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the value unchanged with the fallback folder when unannotated', () => {
|
||||
expect(parseAnnotatedFilename('foo.glb', 'input')).toEqual({
|
||||
filename: 'foo.glb',
|
||||
folder: 'input'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not strip a non-folder annotation', () => {
|
||||
expect(parseAnnotatedFilename('foo.glb [draft]', 'input')).toEqual({
|
||||
filename: 'foo.glb [draft]',
|
||||
folder: 'input'
|
||||
})
|
||||
})
|
||||
|
||||
it('only matches a trailing annotation, not one in the middle', () => {
|
||||
expect(parseAnnotatedFilename('foo [output] bar.glb', 'input')).toEqual({
|
||||
filename: 'foo [output] bar.glb',
|
||||
folder: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3DConfiguration.loadSceneConfig', () => {
|
||||
beforeEach(() => {
|
||||
settingsGetMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns the persisted Scene Config when present, ignoring settings', () => {
|
||||
const stored: SceneConfig = {
|
||||
showGrid: false,
|
||||
backgroundColor: '#123456',
|
||||
backgroundImage: 'bg.png'
|
||||
}
|
||||
const properties = { 'Scene Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
stubSettings({
|
||||
'Comfy.Load3D.ShowGrid': true,
|
||||
'Comfy.Load3D.BackgroundColor': 'aaaaaa'
|
||||
})
|
||||
|
||||
expect(createConfig(properties).loadSceneConfig()).toEqual(stored)
|
||||
expect(settingsGetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to settings and prepends # to the background color', () => {
|
||||
stubSettings({
|
||||
'Comfy.Load3D.ShowGrid': false,
|
||||
'Comfy.Load3D.BackgroundColor': 'abcdef'
|
||||
})
|
||||
|
||||
expect(createConfig().loadSceneConfig()).toEqual({
|
||||
showGrid: false,
|
||||
backgroundColor: '#abcdef',
|
||||
backgroundImage: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3DConfiguration.loadCameraConfig', () => {
|
||||
beforeEach(() => {
|
||||
settingsGetMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns the persisted Camera Config when present', () => {
|
||||
const stored: CameraConfig = {
|
||||
cameraType: 'orthographic',
|
||||
fov: 50
|
||||
}
|
||||
const properties = { 'Camera Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
stubSettings({ 'Comfy.Load3D.CameraType': 'perspective' })
|
||||
|
||||
expect(createConfig(properties).loadCameraConfig()).toEqual(stored)
|
||||
expect(settingsGetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to settings and a default fov of 35', () => {
|
||||
stubSettings({ 'Comfy.Load3D.CameraType': 'perspective' })
|
||||
|
||||
expect(createConfig().loadCameraConfig()).toEqual({
|
||||
cameraType: 'perspective',
|
||||
fov: 35
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3DConfiguration.loadLightConfig', () => {
|
||||
beforeEach(() => {
|
||||
settingsGetMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('falls back to settings with default hdri when nothing is persisted', () => {
|
||||
stubSettings({ 'Comfy.Load3D.LightIntensity': 4 })
|
||||
|
||||
expect(createConfig().loadLightConfig()).toEqual({
|
||||
intensity: 4,
|
||||
hdri: hdriDefaults
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the persisted intensity over the setting when present', () => {
|
||||
const stored: Partial<LightConfig> = { intensity: 7 }
|
||||
const properties = { 'Light Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
stubSettings({ 'Comfy.Load3D.LightIntensity': 4 })
|
||||
|
||||
expect(createConfig(properties).loadLightConfig()).toEqual({
|
||||
intensity: 7,
|
||||
hdri: hdriDefaults
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the setting intensity when persisted intensity is missing', () => {
|
||||
const properties = { 'Light Config': {} } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
stubSettings({ 'Comfy.Load3D.LightIntensity': 4 })
|
||||
|
||||
expect(createConfig(properties).loadLightConfig()).toEqual({
|
||||
intensity: 4,
|
||||
hdri: hdriDefaults
|
||||
})
|
||||
})
|
||||
|
||||
it('merges persisted hdri partial over hdri defaults', () => {
|
||||
const stored: Partial<LightConfig> = {
|
||||
intensity: 2,
|
||||
hdri: { hdriPath: 'env.hdr', enabled: true } as LightConfig['hdri']
|
||||
}
|
||||
const properties = { 'Light Config': stored } as Dictionary<
|
||||
NodeProperty | undefined
|
||||
>
|
||||
|
||||
expect(createConfig(properties).loadLightConfig()).toEqual({
|
||||
intensity: 2,
|
||||
hdri: {
|
||||
enabled: true,
|
||||
hdriPath: 'env.hdr',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Load3DConfiguration.configure forwards persisted + settings to load3d', () => {
|
||||
let load3d: Load3d
|
||||
|
||||
function makeLoad3dMock(): Load3d {
|
||||
return {
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
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()
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
settingsGetMock.mockReset()
|
||||
load3d = makeLoad3dMock()
|
||||
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
|
||||
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('uses settings defaults when no Scene/Camera/Light Config is persisted', async () => {
|
||||
stubSettings({
|
||||
'Comfy.Load3D.ShowGrid': true,
|
||||
'Comfy.Load3D.BackgroundColor': '282828',
|
||||
'Comfy.Load3D.CameraType': 'orthographic',
|
||||
'Comfy.Load3D.LightIntensity': 6
|
||||
})
|
||||
|
||||
const config = new Load3DConfiguration(load3d)
|
||||
config.configure({
|
||||
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
|
||||
loadFolder: 'output'
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.toggleGrid).toHaveBeenCalledWith(true)
|
||||
expect(load3d.setBackgroundColor).toHaveBeenCalledWith('#282828')
|
||||
expect(load3d.toggleCamera).toHaveBeenCalledWith('orthographic')
|
||||
expect(load3d.setFOV).toHaveBeenCalledWith(35)
|
||||
expect(load3d.setLightIntensity).toHaveBeenCalledWith(6)
|
||||
})
|
||||
|
||||
it('prefers persisted Scene/Camera/Light Config over settings', async () => {
|
||||
const properties = {
|
||||
'Scene Config': {
|
||||
showGrid: false,
|
||||
backgroundColor: '#101010',
|
||||
backgroundImage: ''
|
||||
},
|
||||
'Camera Config': { cameraType: 'perspective', fov: 60 },
|
||||
'Light Config': { intensity: 9 }
|
||||
} as unknown as Dictionary<NodeProperty | undefined>
|
||||
stubSettings({
|
||||
'Comfy.Load3D.ShowGrid': true,
|
||||
'Comfy.Load3D.BackgroundColor': '282828',
|
||||
'Comfy.Load3D.CameraType': 'orthographic',
|
||||
'Comfy.Load3D.LightIntensity': 1
|
||||
})
|
||||
|
||||
const config = new Load3DConfiguration(load3d, properties)
|
||||
config.configure({
|
||||
modelWidget: { value: 'model.glb' } as unknown as IBaseWidget,
|
||||
loadFolder: 'output'
|
||||
})
|
||||
await flush()
|
||||
|
||||
expect(load3d.toggleGrid).toHaveBeenCalledWith(false)
|
||||
expect(load3d.setBackgroundColor).toHaveBeenCalledWith('#101010')
|
||||
expect(load3d.toggleCamera).toHaveBeenCalledWith('perspective')
|
||||
expect(load3d.setFOV).toHaveBeenCalledWith(60)
|
||||
expect(load3d.setLightIntensity).toHaveBeenCalledWith(9)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,20 @@ type Load3DConfigurationSettings = {
|
||||
silentOnNotFound?: boolean
|
||||
}
|
||||
|
||||
const ANNOTATED_FILENAME_PATTERN = / \[(input|output|temp)\]$/
|
||||
|
||||
export function parseAnnotatedFilename(
|
||||
rawValue: string,
|
||||
fallbackFolder: string
|
||||
): { filename: string; folder: string } {
|
||||
const match = ANNOTATED_FILENAME_PATTERN.exec(rawValue)
|
||||
if (!match) return { filename: rawValue, folder: fallbackFolder }
|
||||
return {
|
||||
filename: rawValue.slice(0, match.index),
|
||||
folder: match[1]
|
||||
}
|
||||
}
|
||||
|
||||
class Load3DConfiguration {
|
||||
constructor(
|
||||
private load3d: Load3d,
|
||||
@@ -268,14 +282,17 @@ class Load3DConfiguration {
|
||||
return async (value: string | number | boolean | object) => {
|
||||
if (!value) return
|
||||
|
||||
const filename = value as string
|
||||
const { filename, folder } = parseAnnotatedFilename(
|
||||
value as string,
|
||||
loadFolder
|
||||
)
|
||||
|
||||
this.setResourceFolder(filename)
|
||||
|
||||
const modelUrl = api.apiURL(
|
||||
Load3dUtils.getResourceURL(
|
||||
...Load3dUtils.splitFilePath(filename),
|
||||
loadFolder
|
||||
folder
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -280,7 +280,7 @@ describe('Load3d', () => {
|
||||
const sceneResize = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderer: { domElement: canvas, setSize },
|
||||
renderer: { domElement: canvas, setSize, setPixelRatio: vi.fn() },
|
||||
targetWidth: 400,
|
||||
targetHeight: 200,
|
||||
targetAspectRatio: 2,
|
||||
@@ -383,6 +383,70 @@ describe('Load3d', () => {
|
||||
expect(args[2]).toBe(800)
|
||||
expect(args[3]).toBe(400)
|
||||
})
|
||||
|
||||
it('handleResize calls setPixelRatio with the value returned by getZoomScaleCallback', () => {
|
||||
delete (ctx.load3d as { handleResize?: unknown }).handleResize
|
||||
|
||||
const parent = document.createElement('div')
|
||||
Object.defineProperty(parent, 'clientWidth', {
|
||||
value: 400,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(parent, 'clientHeight', {
|
||||
value: 400,
|
||||
configurable: true
|
||||
})
|
||||
const canvas = document.createElement('canvas')
|
||||
parent.appendChild(canvas)
|
||||
|
||||
const setPixelRatio = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
|
||||
getZoomScaleCallback: () => 2.5,
|
||||
targetWidth: 0,
|
||||
targetHeight: 0,
|
||||
isViewerMode: false,
|
||||
cameraManager: { ...ctx.cameraManager, handleResize: vi.fn() },
|
||||
sceneManager: { ...ctx.sceneManager, handleResize: vi.fn() }
|
||||
})
|
||||
|
||||
ctx.load3d.handleResize()
|
||||
|
||||
expect(setPixelRatio).toHaveBeenCalledWith(2.5)
|
||||
})
|
||||
|
||||
it('handleResize defaults to pixelRatio 1 when no getZoomScaleCallback is provided', () => {
|
||||
delete (ctx.load3d as { handleResize?: unknown }).handleResize
|
||||
|
||||
const parent = document.createElement('div')
|
||||
Object.defineProperty(parent, 'clientWidth', {
|
||||
value: 400,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(parent, 'clientHeight', {
|
||||
value: 400,
|
||||
configurable: true
|
||||
})
|
||||
const canvas = document.createElement('canvas')
|
||||
parent.appendChild(canvas)
|
||||
|
||||
const setPixelRatio = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
|
||||
getZoomScaleCallback: undefined,
|
||||
targetWidth: 0,
|
||||
targetHeight: 0,
|
||||
isViewerMode: false,
|
||||
cameraManager: { ...ctx.cameraManager, handleResize: vi.fn() },
|
||||
sceneManager: { ...ctx.sceneManager, handleResize: vi.fn() }
|
||||
})
|
||||
|
||||
ctx.load3d.handleResize()
|
||||
|
||||
expect(setPixelRatio).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('render loop wiring', () => {
|
||||
|
||||
@@ -102,6 +102,7 @@ class Load3d {
|
||||
|
||||
private disposeContextMenuGuard: (() => void) | null = null
|
||||
private resizeObserver: ResizeObserver | null = null
|
||||
private getZoomScaleCallback: (() => number) | undefined
|
||||
|
||||
constructor(
|
||||
container: Element | HTMLElement,
|
||||
@@ -112,6 +113,7 @@ class Load3d {
|
||||
this.isViewerMode = options.isViewerMode || false
|
||||
this.onContextMenuCallback = options.onContextMenu
|
||||
this.getDimensionsCallback = options.getDimensions
|
||||
this.getZoomScaleCallback = options.getZoomScale
|
||||
|
||||
if (options.width && options.height) {
|
||||
this.targetWidth = options.width
|
||||
@@ -645,6 +647,11 @@ class Load3d {
|
||||
const containerWidth = parentElement.clientWidth
|
||||
const containerHeight = parentElement.clientHeight
|
||||
|
||||
// Scale pixel density to match the graph zoom level so the 3D scene
|
||||
// renders at the correct resolution when the canvas is zoomed in or out.
|
||||
const zoomScale = this.getZoomScaleCallback?.() ?? 1
|
||||
this.renderer.setPixelRatio(Math.min(zoomScale, 3))
|
||||
|
||||
if (this.getDimensionsCallback) {
|
||||
const dims = this.getDimensionsCallback()
|
||||
if (dims) {
|
||||
|
||||
@@ -25,6 +25,37 @@ vi.mock('three', async (importOriginal) => {
|
||||
return { ...actual, TextureLoader: StubTextureLoader }
|
||||
})
|
||||
|
||||
vi.mock('three/examples/jsm/controls/OrbitControls', () => {
|
||||
class OrbitControls {}
|
||||
return { OrbitControls }
|
||||
})
|
||||
|
||||
function makeMockRenderer(pixelRatio = 1): THREE.WebGLRenderer {
|
||||
const domElement = {
|
||||
toDataURL: vi.fn().mockReturnValue('data:image/png;base64,abc'),
|
||||
clientWidth: 400,
|
||||
clientHeight: 300
|
||||
}
|
||||
return {
|
||||
domElement,
|
||||
outputColorSpace: THREE.SRGBColorSpace,
|
||||
toneMapping: THREE.ACESFilmicToneMapping,
|
||||
toneMappingExposure: 1,
|
||||
getSize: vi.fn((v: THREE.Vector2) => {
|
||||
v.set(400, 300)
|
||||
return v
|
||||
}),
|
||||
getPixelRatio: vi.fn().mockReturnValue(pixelRatio),
|
||||
getClearColor: vi.fn((c: THREE.Color) => c),
|
||||
getClearAlpha: vi.fn().mockReturnValue(0),
|
||||
setPixelRatio: vi.fn(),
|
||||
setSize: vi.fn(),
|
||||
setClearColor: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
render: vi.fn()
|
||||
} as unknown as THREE.WebGLRenderer
|
||||
}
|
||||
|
||||
function makeMockEventManager() {
|
||||
return {
|
||||
addEventListener: vi.fn(),
|
||||
@@ -50,6 +81,12 @@ function makeRenderer() {
|
||||
domElement: canvas,
|
||||
setClearColor: vi.fn(),
|
||||
setSize: vi.fn(),
|
||||
getSize: vi.fn((v: THREE.Vector2) => {
|
||||
v.set(800, 600)
|
||||
return v
|
||||
}),
|
||||
getPixelRatio: vi.fn().mockReturnValue(1),
|
||||
setPixelRatio: vi.fn(),
|
||||
render: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
getClearColor: vi.fn().mockReturnValue(new THREE.Color(0xffffff)),
|
||||
@@ -544,3 +581,90 @@ describe('SceneManager', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function makeSceneManager(
|
||||
pixelRatio = 1,
|
||||
cameraOverride?: THREE.PerspectiveCamera | THREE.OrthographicCamera
|
||||
) {
|
||||
const renderer = makeMockRenderer(pixelRatio)
|
||||
const camera = cameraOverride ?? new THREE.PerspectiveCamera()
|
||||
const eventManager = makeMockEventManager()
|
||||
const manager = new SceneManager(
|
||||
renderer,
|
||||
() => camera,
|
||||
vi.fn() as unknown as () => InstanceType<
|
||||
typeof import('three/examples/jsm/controls/OrbitControls').OrbitControls
|
||||
>,
|
||||
eventManager
|
||||
)
|
||||
return { manager, renderer, camera, eventManager }
|
||||
}
|
||||
|
||||
describe('SceneManager.captureScene', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('resolves with scene, mask, and normal data URLs', async () => {
|
||||
const { manager } = makeSceneManager()
|
||||
const result = await manager.captureScene(800, 600)
|
||||
expect(result.scene).toContain('data:image/png')
|
||||
expect(result.mask).toContain('data:image/png')
|
||||
expect(result.normal).toContain('data:image/png')
|
||||
})
|
||||
|
||||
it('forces pixel ratio to 1 before rendering regardless of original value', async () => {
|
||||
const { manager, renderer } = makeSceneManager(2)
|
||||
await manager.captureScene(800, 600)
|
||||
expect(vi.mocked(renderer.setPixelRatio).mock.calls[0]).toEqual([1])
|
||||
})
|
||||
|
||||
it('restores original pixel ratio after capture completes', async () => {
|
||||
const originalPixelRatio = 3
|
||||
const { manager, renderer } = makeSceneManager(originalPixelRatio)
|
||||
await manager.captureScene(800, 600)
|
||||
const calls = vi.mocked(renderer.setPixelRatio).mock.calls
|
||||
expect(calls.at(-1)).toEqual([originalPixelRatio])
|
||||
})
|
||||
|
||||
it('renders at requested capture dimensions', async () => {
|
||||
const { manager, renderer } = makeSceneManager()
|
||||
await manager.captureScene(1920, 1080)
|
||||
expect(vi.mocked(renderer.setSize).mock.calls[0]).toEqual([1920, 1080])
|
||||
})
|
||||
|
||||
it('restores original renderer size after capture', async () => {
|
||||
const { manager, renderer } = makeSceneManager()
|
||||
await manager.captureScene(1920, 1080)
|
||||
const calls = vi.mocked(renderer.setSize).mock.calls
|
||||
expect(calls.at(-1)).toEqual([400, 300])
|
||||
})
|
||||
|
||||
it('restores perspective camera aspect after capture', async () => {
|
||||
const camera = new THREE.PerspectiveCamera(75, 1, 0.1, 1000)
|
||||
const { manager } = makeSceneManager(1, camera)
|
||||
const originalAspect = camera.aspect
|
||||
await manager.captureScene(800, 600)
|
||||
expect(camera.aspect).toBe(originalAspect)
|
||||
})
|
||||
|
||||
it('restores orthographic camera bounds after capture', async () => {
|
||||
const camera = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.1, 1000)
|
||||
const { manager } = makeSceneManager(1, camera)
|
||||
await manager.captureScene(800, 600)
|
||||
expect(camera.left).toBe(-5)
|
||||
expect(camera.right).toBe(5)
|
||||
expect(camera.top).toBe(5)
|
||||
expect(camera.bottom).toBe(-5)
|
||||
})
|
||||
|
||||
it('disposes each temporary MeshNormalMaterial after the normal pass', async () => {
|
||||
const { manager } = makeSceneManager()
|
||||
manager.scene.add(
|
||||
new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial())
|
||||
)
|
||||
const disposeSpy = vi.spyOn(THREE.MeshNormalMaterial.prototype, 'dispose')
|
||||
await manager.captureScene(800, 600)
|
||||
expect(disposeSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -332,120 +332,138 @@ export class SceneManager implements SceneManagerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
captureScene(
|
||||
async captureScene(
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<{ scene: string; mask: string; normal: string }> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const originalWidth = this.renderer.domElement.width
|
||||
const originalHeight = this.renderer.domElement.height
|
||||
const originalClearColor = this.renderer.getClearColor(
|
||||
new THREE.Color()
|
||||
)
|
||||
const originalClearAlpha = this.renderer.getClearAlpha()
|
||||
const originalOutputColorSpace = this.renderer.outputColorSpace
|
||||
const originalSize = new THREE.Vector2()
|
||||
this.renderer.getSize(originalSize)
|
||||
const originalPixelRatio = this.renderer.getPixelRatio()
|
||||
const originalClearColor = this.renderer.getClearColor(new THREE.Color())
|
||||
const originalClearAlpha = this.renderer.getClearAlpha()
|
||||
const originalOutputColorSpace = this.renderer.outputColorSpace
|
||||
|
||||
this.renderer.setSize(width, height)
|
||||
|
||||
if (this.getActiveCamera() instanceof THREE.PerspectiveCamera) {
|
||||
const perspectiveCamera =
|
||||
this.getActiveCamera() as THREE.PerspectiveCamera
|
||||
|
||||
perspectiveCamera.aspect = width / height
|
||||
perspectiveCamera.updateProjectionMatrix()
|
||||
} else {
|
||||
const orthographicCamera =
|
||||
this.getActiveCamera() as THREE.OrthographicCamera
|
||||
|
||||
const frustumSize = 10
|
||||
const aspect = width / height
|
||||
|
||||
orthographicCamera.left = (-frustumSize * aspect) / 2
|
||||
orthographicCamera.right = (frustumSize * aspect) / 2
|
||||
orthographicCamera.top = frustumSize / 2
|
||||
orthographicCamera.bottom = -frustumSize / 2
|
||||
|
||||
orthographicCamera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
if (
|
||||
this.backgroundTexture &&
|
||||
this.backgroundMesh &&
|
||||
this.currentBackgroundType === 'image'
|
||||
) {
|
||||
this.updateBackgroundSize(
|
||||
this.backgroundTexture,
|
||||
this.backgroundMesh,
|
||||
width,
|
||||
height
|
||||
)
|
||||
}
|
||||
|
||||
const originalMaterials = new Map<
|
||||
THREE.Mesh,
|
||||
THREE.Material | THREE.Material[]
|
||||
>()
|
||||
|
||||
this.renderer.clear()
|
||||
this.renderBackground()
|
||||
this.renderer.render(this.scene, this.getActiveCamera())
|
||||
const sceneData = this.renderer.domElement.toDataURL('image/png')
|
||||
|
||||
this.renderer.setClearColor(0x000000, 0)
|
||||
this.renderer.clear()
|
||||
this.renderer.render(this.scene, this.getActiveCamera())
|
||||
const maskData = this.renderer.domElement.toDataURL('image/png')
|
||||
|
||||
this.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
originalMaterials.set(child, child.material)
|
||||
|
||||
child.material = new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
side: THREE.DoubleSide,
|
||||
normalScale: new THREE.Vector2(1, 1)
|
||||
})
|
||||
const activeCamera = this.getActiveCamera()
|
||||
const savedCameraParams =
|
||||
activeCamera instanceof THREE.PerspectiveCamera
|
||||
? { type: 'perspective' as const, aspect: activeCamera.aspect }
|
||||
: {
|
||||
type: 'orthographic' as const,
|
||||
left: (activeCamera as THREE.OrthographicCamera).left,
|
||||
right: (activeCamera as THREE.OrthographicCamera).right,
|
||||
top: (activeCamera as THREE.OrthographicCamera).top,
|
||||
bottom: (activeCamera as THREE.OrthographicCamera).bottom
|
||||
}
|
||||
})
|
||||
|
||||
const gridVisible = this.gridHelper.visible
|
||||
this.gridHelper.visible = false
|
||||
const originalMaterials = new Map<
|
||||
THREE.Mesh,
|
||||
THREE.Material | THREE.Material[]
|
||||
>()
|
||||
const tempMaterials: THREE.MeshNormalMaterial[] = []
|
||||
const gridVisible = this.gridHelper.visible
|
||||
|
||||
this.renderer.setClearColor(0x000000, 1)
|
||||
this.renderer.clear()
|
||||
this.renderer.render(this.scene, this.getActiveCamera())
|
||||
const normalData = this.renderer.domElement.toDataURL('image/png')
|
||||
try {
|
||||
// Capture at exactly the requested pixel dimensions, independent of
|
||||
// the current zoom-driven pixel ratio.
|
||||
this.renderer.setPixelRatio(1)
|
||||
this.renderer.setSize(width, height)
|
||||
|
||||
this.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
const originalMaterial = originalMaterials.get(child)
|
||||
if (originalMaterial) {
|
||||
child.material = originalMaterial
|
||||
}
|
||||
}
|
||||
})
|
||||
if (activeCamera instanceof THREE.PerspectiveCamera) {
|
||||
activeCamera.aspect = width / height
|
||||
activeCamera.updateProjectionMatrix()
|
||||
} else {
|
||||
const orthographicCamera = activeCamera as THREE.OrthographicCamera
|
||||
|
||||
this.renderer.setClearColor(0xffffff, 1)
|
||||
this.renderer.clear()
|
||||
const frustumSize = 10
|
||||
const aspect = width / height
|
||||
|
||||
this.gridHelper.visible = gridVisible
|
||||
orthographicCamera.left = (-frustumSize * aspect) / 2
|
||||
orthographicCamera.right = (frustumSize * aspect) / 2
|
||||
orthographicCamera.top = frustumSize / 2
|
||||
orthographicCamera.bottom = -frustumSize / 2
|
||||
|
||||
this.renderer.setClearColor(originalClearColor, originalClearAlpha)
|
||||
this.renderer.setSize(originalWidth, originalHeight)
|
||||
this.renderer.outputColorSpace = originalOutputColorSpace
|
||||
|
||||
this.handleResize(originalWidth, originalHeight)
|
||||
|
||||
resolve({
|
||||
scene: sceneData,
|
||||
mask: maskData,
|
||||
normal: normalData
|
||||
})
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
orthographicCamera.updateProjectionMatrix()
|
||||
}
|
||||
})
|
||||
|
||||
if (
|
||||
this.backgroundTexture &&
|
||||
this.backgroundMesh &&
|
||||
this.currentBackgroundType === 'image'
|
||||
) {
|
||||
this.updateBackgroundSize(
|
||||
this.backgroundTexture,
|
||||
this.backgroundMesh,
|
||||
width,
|
||||
height
|
||||
)
|
||||
}
|
||||
|
||||
this.renderer.clear()
|
||||
this.renderBackground()
|
||||
this.renderer.render(this.scene, activeCamera)
|
||||
const sceneData = this.renderer.domElement.toDataURL('image/png')
|
||||
|
||||
this.renderer.setClearColor(0x000000, 0)
|
||||
this.renderer.clear()
|
||||
this.renderer.render(this.scene, activeCamera)
|
||||
const maskData = this.renderer.domElement.toDataURL('image/png')
|
||||
|
||||
this.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
originalMaterials.set(child, child.material)
|
||||
|
||||
const tempMaterial = new THREE.MeshNormalMaterial({
|
||||
flatShading: false,
|
||||
side: THREE.DoubleSide,
|
||||
normalScale: new THREE.Vector2(1, 1)
|
||||
})
|
||||
tempMaterials.push(tempMaterial)
|
||||
child.material = tempMaterial
|
||||
}
|
||||
})
|
||||
|
||||
this.gridHelper.visible = false
|
||||
|
||||
this.renderer.setClearColor(0x000000, 1)
|
||||
this.renderer.clear()
|
||||
this.renderer.render(this.scene, activeCamera)
|
||||
const normalData = this.renderer.domElement.toDataURL('image/png')
|
||||
|
||||
this.renderer.setClearColor(0xffffff, 1)
|
||||
this.renderer.clear()
|
||||
|
||||
return { scene: sceneData, mask: maskData, normal: normalData }
|
||||
} finally {
|
||||
this.scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
const originalMaterial = originalMaterials.get(child)
|
||||
if (originalMaterial) {
|
||||
child.material = originalMaterial
|
||||
}
|
||||
}
|
||||
})
|
||||
for (const mat of tempMaterials) {
|
||||
mat.dispose()
|
||||
}
|
||||
this.gridHelper.visible = gridVisible
|
||||
if (savedCameraParams.type === 'perspective') {
|
||||
const persp = activeCamera as THREE.PerspectiveCamera
|
||||
persp.aspect = savedCameraParams.aspect
|
||||
persp.updateProjectionMatrix()
|
||||
} else {
|
||||
const ortho = activeCamera as THREE.OrthographicCamera
|
||||
ortho.left = savedCameraParams.left
|
||||
ortho.right = savedCameraParams.right
|
||||
ortho.top = savedCameraParams.top
|
||||
ortho.bottom = savedCameraParams.bottom
|
||||
ortho.updateProjectionMatrix()
|
||||
}
|
||||
this.renderer.setClearColor(originalClearColor, originalClearAlpha)
|
||||
this.renderer.setPixelRatio(originalPixelRatio)
|
||||
this.renderer.setSize(originalSize.x, originalSize.y)
|
||||
this.renderer.outputColorSpace = originalOutputColorSpace
|
||||
this.handleResize(originalSize.x, originalSize.y)
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {}
|
||||
|
||||
@@ -211,20 +211,40 @@ describe('SceneModelManager', () => {
|
||||
expect(setupCamera).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not skip materialMode when it differs from original', async () => {
|
||||
it('reapplies non-original materialMode after snapshotting', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
|
||||
// setupModel checks materialMode !== 'original' and calls
|
||||
// setMaterialMode, but the guard `mode === this.materialMode`
|
||||
// causes it to no-op. Then setupModelMaterials resets to 'original'.
|
||||
// setupModel calls setupModelMaterials first (which internally calls
|
||||
// setMaterialMode('original') to reset), then reapplies the stored mode.
|
||||
manager.materialMode = 'wireframe'
|
||||
const spy = vi.spyOn(manager, 'setMaterialMode')
|
||||
await manager.setupModel(model)
|
||||
|
||||
// setMaterialMode is called with the stored mode and then 'original'
|
||||
expect(spy).toHaveBeenCalledWith('wireframe')
|
||||
expect(spy).toHaveBeenCalledWith('original')
|
||||
expect(spy).toHaveBeenCalledWith('wireframe')
|
||||
// The final material mode visible on the mesh should be wireframe.
|
||||
const mesh = model.children[0] as THREE.Mesh
|
||||
expect((mesh.material as THREE.MeshBasicMaterial).wireframe).toBe(true)
|
||||
})
|
||||
|
||||
it('snapshots original materials before applying materialMode so restore is correct', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
const mesh = model.children[0] as THREE.Mesh
|
||||
const originalMat = mesh.material
|
||||
|
||||
// Set a non-original mode before loading — this was the bug:
|
||||
// originalMaterials would capture the wireframe material instead of the real one.
|
||||
manager.materialMode = 'wireframe'
|
||||
await manager.setupModel(model)
|
||||
|
||||
// The snapshot must hold the *pre-mutation* material.
|
||||
expect(manager.originalMaterials.get(mesh)).toBe(originalMat)
|
||||
|
||||
// Restoring to 'original' must give back the true original, not wireframe.
|
||||
manager.setMaterialMode('original')
|
||||
expect(mesh.material).toBe(originalMat)
|
||||
})
|
||||
|
||||
it('applies current up direction if not original', async () => {
|
||||
@@ -679,6 +699,80 @@ describe('SceneModelManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('fitToViewer', () => {
|
||||
it('does nothing when no current model', () => {
|
||||
const { manager, setupCamera, setupGizmo } = createManager()
|
||||
|
||||
manager.fitToViewer()
|
||||
|
||||
expect(setupCamera).not.toHaveBeenCalled()
|
||||
expect(setupGizmo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reapplies currentUpDirection after fitting', async () => {
|
||||
const { manager, eventManager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
manager.setUpDirection('+z')
|
||||
vi.mocked(eventManager.emitEvent).mockClear()
|
||||
|
||||
manager.fitToViewer()
|
||||
|
||||
// rotation.x should reflect +z direction (-PI/2) applied to the post-fit base (0,0,0)
|
||||
expect(model.rotation.x).toBeCloseTo(-Math.PI / 2)
|
||||
expect(eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'upDirectionChange',
|
||||
'+z'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not compound rotations when fitToViewer is called multiple times', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
manager.setUpDirection('-x')
|
||||
|
||||
manager.fitToViewer()
|
||||
const rotationAfterFirst = model.rotation.z
|
||||
|
||||
manager.fitToViewer()
|
||||
expect(model.rotation.z).toBeCloseTo(rotationAfterFirst)
|
||||
})
|
||||
|
||||
it('leaves rotation at zero when currentUpDirection is original', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
manager.fitToViewer()
|
||||
|
||||
expect(model.rotation.x).toBeCloseTo(0)
|
||||
expect(model.rotation.y).toBeCloseTo(0)
|
||||
expect(model.rotation.z).toBeCloseTo(0)
|
||||
})
|
||||
|
||||
it('does not compound rotation when fitToViewer is called after manual rotation override', async () => {
|
||||
const { manager } = createManager()
|
||||
const model = createMeshModel()
|
||||
await manager.setupModel(model)
|
||||
|
||||
// Set an up direction, then manually override originalRotation to simulate
|
||||
// a prior state where the base rotation was non-zero before fit
|
||||
manager.setUpDirection('+x')
|
||||
// Simulate that originalRotation was captured at a non-zero rotation
|
||||
manager.originalRotation = new THREE.Euler(0.5, 0.3, 0.1)
|
||||
|
||||
manager.fitToViewer()
|
||||
|
||||
// After fit, the rotation should be correct for +x direction applied to (0,0,0) base
|
||||
// Not compounded with the stale originalRotation
|
||||
expect(model.rotation.x).toBeCloseTo(0)
|
||||
expect(model.rotation.z).toBeCloseTo(-Math.PI / 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PLY mode switching', () => {
|
||||
function createPLYManager() {
|
||||
const ctx = createManager({
|
||||
|
||||
@@ -447,15 +447,16 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
}
|
||||
|
||||
this.scene.add(model)
|
||||
const pendingMaterialMode = this.materialMode
|
||||
this.setupModelMaterials(model)
|
||||
|
||||
if (this.materialMode !== 'original') {
|
||||
this.setMaterialMode(this.materialMode)
|
||||
if (pendingMaterialMode !== 'original') {
|
||||
this.setMaterialMode(pendingMaterialMode)
|
||||
}
|
||||
|
||||
if (this.currentUpDirection !== 'original') {
|
||||
this.setUpDirection(this.currentUpDirection)
|
||||
}
|
||||
this.setupModelMaterials(model)
|
||||
|
||||
const box = this.computeWorldBounds(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
@@ -491,6 +492,13 @@ export class SceneModelManager implements ModelManagerInterface {
|
||||
|
||||
model.position.set(-center.x, -scaledBox.min.y, -center.z)
|
||||
|
||||
// fitToViewer resets rotation to (0,0,0), so originalRotation must be cleared
|
||||
// before reapplying currentUpDirection to avoid compounding rotations.
|
||||
this.originalRotation = null
|
||||
if (this.currentUpDirection !== 'original') {
|
||||
this.setUpDirection(this.currentUpDirection)
|
||||
}
|
||||
|
||||
const newBox = this.computeWorldBounds(model)
|
||||
const newSize = newBox.getSize(new THREE.Vector3())
|
||||
const newCenter = newBox.getCenter(new THREE.Vector3())
|
||||
|
||||
@@ -77,6 +77,11 @@ export interface Load3DOptions {
|
||||
// Use this for reactive dimensions that change over time
|
||||
getDimensions?: () => { width: number; height: number } | null
|
||||
|
||||
// Returns the current canvas zoom scale (e.g. ds.scale from LiteGraph).
|
||||
// Used to scale the renderer pixel ratio so the 3D scene renders at the
|
||||
// correct resolution when the graph is zoomed in or out.
|
||||
getZoomScale?: () => number
|
||||
|
||||
// Viewer mode flag (affects aspect ratio behavior)
|
||||
isViewerMode?: boolean
|
||||
|
||||
|
||||
@@ -461,6 +461,12 @@
|
||||
"wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟"
|
||||
},
|
||||
"survey": {
|
||||
"errors": {
|
||||
"chooseAnOption": "يرجى اختيار خيار.",
|
||||
"describeAnswer": "يرجى وصف إجابتك.",
|
||||
"selectAtLeastOne": "يرجى اختيار خيار واحد على الأقل."
|
||||
},
|
||||
"intro": "ساعدنا في تخصيص تجربتك مع ComfyUI.",
|
||||
"options": {
|
||||
"familiarity": {
|
||||
"advanced": "مستخدم متقدم (سير عمل مخصصة)",
|
||||
@@ -469,45 +475,43 @@
|
||||
"new": "جديد في ComfyUI (لم أستخدمه من قبل)",
|
||||
"starting": "في البداية فقط (أتابع الدروس التعليمية)"
|
||||
},
|
||||
"industry": {
|
||||
"architecture": "الهندسة المعمارية",
|
||||
"education": "التعليم",
|
||||
"film_tv_animation": "الأفلام والتلفزيون والرسوم المتحركة",
|
||||
"fine_art": "الفنون الجميلة والرسوم التوضيحية",
|
||||
"gaming": "الألعاب",
|
||||
"marketing": "التسويق والإعلان",
|
||||
"intent": {
|
||||
"3d_game": "أصول ثلاثية الأبعاد / أصول ألعاب",
|
||||
"api": "نقاط نهاية API لتشغيل مسارات العمل",
|
||||
"apps": "تطبيقات مبسطة من مسارات العمل",
|
||||
"audio": "صوت / موسيقى",
|
||||
"custom_nodes": "عُقد مخصصة",
|
||||
"images": "صور",
|
||||
"not_sure": "لست متأكداً",
|
||||
"videos": "فيديوهات",
|
||||
"workflows": "مسارات عمل أو خطوط معالجة مخصصة"
|
||||
},
|
||||
"source": {
|
||||
"conference": "مؤتمر أو فعالية",
|
||||
"discord": "ديسكورد / مجتمع",
|
||||
"friend": "صديق أو زميل",
|
||||
"github": "GitHub",
|
||||
"instagram": "إنستغرام",
|
||||
"linkedin": "لينكدإن",
|
||||
"newsletter": "النشرة البريدية أو مدونة",
|
||||
"other": "أخرى",
|
||||
"otherPlaceholder": "يرجى التحديد",
|
||||
"product_design": "تصميم المنتجات والرسوم",
|
||||
"software": "البرمجيات والتكنولوجيا"
|
||||
"reddit": "ريديت",
|
||||
"search": "جوجل / بحث",
|
||||
"twitter": "تويتر / X",
|
||||
"youtube": "يوتيوب"
|
||||
},
|
||||
"making": {
|
||||
"3d": "الأصول ثلاثية الأبعاد",
|
||||
"audio": "الصوت / الموسيقى",
|
||||
"custom_nodes": "العُقد وسير العمل المخصصة",
|
||||
"images": "الصور",
|
||||
"video": "الفيديو والرسوم المتحركة"
|
||||
},
|
||||
"purpose": {
|
||||
"client": "العمل للعملاء (العمل الحر)",
|
||||
"community": "مساهمات مجتمعية (عُقد، سير عمل، إلخ)",
|
||||
"inhouse": "مكان عملي الخاص (داخلي)",
|
||||
"personal": "مشاريع شخصية / هواية",
|
||||
"research": "البحث الأكاديمي"
|
||||
"usage": {
|
||||
"education": "تعليمي (طالب أو معلم)",
|
||||
"personal": "استخدام شخصي",
|
||||
"work": "عمل"
|
||||
}
|
||||
},
|
||||
"placeholder": "نص بديل لأسئلة الاستبيان",
|
||||
"questions": {
|
||||
"familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
||||
"industry": "ما هي صناعتك الأساسية؟",
|
||||
"making": "ماذا تخطط لصنعه؟",
|
||||
"purpose": "ما الاستخدام الأساسي الذي تخطط لـ ComfyUI من أجله؟"
|
||||
},
|
||||
"steps": {
|
||||
"familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
||||
"industry": "ما هي صناعتك الأساسية؟",
|
||||
"making": "ماذا تخطط لصنعه؟",
|
||||
"purpose": "ما الاستخدام الأساسي الذي ستستخدم ComfyUI من أجله؟"
|
||||
"intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
|
||||
"source": "من أين سمعت عن ComfyUI؟",
|
||||
"usage": "كيف تخطط لاستخدام ComfyUI؟"
|
||||
},
|
||||
"title": "استبيان السحابة"
|
||||
}
|
||||
@@ -522,9 +526,9 @@
|
||||
"cloudStart_title": "ابدأ الإبداع في ثوانٍ",
|
||||
"cloudStart_wantToRun": "هل تريد تشغيل ComfyUI محليًا بدلاً من ذلك؟",
|
||||
"cloudSurvey_steps_familiarity": "ما مدى معرفتك بـ ComfyUI؟",
|
||||
"cloudSurvey_steps_industry": "ما مجال عملك الأساسي؟",
|
||||
"cloudSurvey_steps_making": "ماذا تخطط لصنعه؟",
|
||||
"cloudSurvey_steps_purpose": "ما الاستخدام الأساسي الذي تخطط لـ ComfyUI من أجله؟",
|
||||
"cloudSurvey_steps_intent": "ما الذي ترغب في إنشائه باستخدام ComfyUI؟",
|
||||
"cloudSurvey_steps_source": "من أين سمعت عن ComfyUI؟",
|
||||
"cloudSurvey_steps_usage": "كيف تخطط لاستخدام ComfyUI؟",
|
||||
"cloudWaitlist_contactLink": "هنا",
|
||||
"cloudWaitlist_questionsText": "أسئلة؟ اتصل بنا",
|
||||
"color": {
|
||||
@@ -2259,7 +2263,6 @@
|
||||
"Magnific": "Magnific",
|
||||
"Meshy": "Meshy",
|
||||
"MiniMax": "MiniMax",
|
||||
"Moonvalley Marey": "مون فالي ماري",
|
||||
"OpenAI": "OpenAI",
|
||||
"PixVerse": "PixVerse",
|
||||
"Quiver": "Quiver",
|
||||
|
||||
@@ -11489,120 +11489,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"MoonvalleyImg2VideoNode": {
|
||||
"description": "عقدة Moonvalley Marey صورة إلى فيديو",
|
||||
"display_name": "Moonvalley Marey صورة إلى فيديو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"image": {
|
||||
"name": "الصورة",
|
||||
"tooltip": "الصورة المرجعية المستخدمة لتوليد الفيديو"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "النص التوجيهي السلبي",
|
||||
"tooltip": "نص التوجيه السلبي"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "النص التوجيهي"
|
||||
},
|
||||
"prompt_adherence": {
|
||||
"name": "الالتزام بالنص التوجيهي",
|
||||
"tooltip": "مقياس التوجيه للتحكم في التوليد"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "الدقة",
|
||||
"tooltip": "دقة الفيديو الناتج"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "قيمة البذرة العشوائية"
|
||||
},
|
||||
"steps": {
|
||||
"name": "الخطوات",
|
||||
"tooltip": "عدد خطوات إزالة الضوضاء"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"MoonvalleyTxt2VideoNode": {
|
||||
"display_name": "Moonvalley Marey نص إلى فيديو",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "النص التوجيهي السلبي",
|
||||
"tooltip": "نص التوجيه السلبي"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "النص التوجيهي"
|
||||
},
|
||||
"prompt_adherence": {
|
||||
"name": "الالتزام بالنص التوجيهي",
|
||||
"tooltip": "مقياس التوجيه للتحكم في التوليد"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "الدقة",
|
||||
"tooltip": "دقة الفيديو الناتج"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "قيمة البذرة العشوائية"
|
||||
},
|
||||
"steps": {
|
||||
"name": "الخطوات",
|
||||
"tooltip": "خطوات الاستدلال"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"MoonvalleyVideo2VideoNode": {
|
||||
"display_name": "Moonvalley Marey من فيديو إلى فيديو",
|
||||
"inputs": {
|
||||
"control_type": {
|
||||
"name": "نوع التحكم"
|
||||
},
|
||||
"motion_intensity": {
|
||||
"name": "شدة الحركة",
|
||||
"tooltip": "يستخدم فقط إذا كان نوع التحكم هو 'نقل الحركة'"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "النص التوجيهي السلبي",
|
||||
"tooltip": "نص النص التوجيهي السلبي"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "النص التوجيهي",
|
||||
"tooltip": "يصف الفيديو المراد توليده"
|
||||
},
|
||||
"seed": {
|
||||
"name": "البذرة",
|
||||
"tooltip": "قيمة البذرة العشوائية"
|
||||
},
|
||||
"steps": {
|
||||
"name": "الخطوات",
|
||||
"tooltip": "عدد خطوات الاستدلال"
|
||||
},
|
||||
"video": {
|
||||
"name": "الفيديو",
|
||||
"tooltip": "الفيديو المرجعي المستخدم لتوليد الفيديو الناتج. يجب أن يكون طوله 5 ثوانٍ على الأقل. سيتم اقتصاص مقاطع الفيديو الأطول من 5 ثوانٍ تلقائيًا. يدعم فقط تنسيق MP4."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Morphology": {
|
||||
"display_name": "مورفولوجيا الصورة",
|
||||
"inputs": {
|
||||
@@ -12534,7 +12420,7 @@
|
||||
"PrimitiveInt": {
|
||||
"display_name": "عدد صحيح",
|
||||
"inputs": {
|
||||
"control_after_generate": {
|
||||
"fixed": {
|
||||
"name": "التحكم بعد التوليد"
|
||||
},
|
||||
"value": {
|
||||
@@ -16624,6 +16510,9 @@
|
||||
"TextGenerate": {
|
||||
"display_name": "توليد نص",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "صوت"
|
||||
},
|
||||
"clip": {
|
||||
"name": "clip"
|
||||
},
|
||||
@@ -16667,6 +16556,10 @@
|
||||
"use_default_template": {
|
||||
"name": "استخدام القالب الافتراضي",
|
||||
"tooltip": "استخدم القالب/الوصف المدمج في النظام إذا كان النموذج يحتوي عليه."
|
||||
},
|
||||
"video": {
|
||||
"name": "فيديو",
|
||||
"tooltip": "إطارات الفيديو كمجموعة صور. يُفترض أن يكون بمعدل ٢٤ إطارًا في الثانية؛ يتم أخذ عينة فرعية إلى ١ إطار في الثانية داخليًا."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -16679,6 +16572,9 @@
|
||||
"TextGenerateLTX2Prompt": {
|
||||
"display_name": "TextGenerateLTX2Prompt",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "صوت"
|
||||
},
|
||||
"clip": {
|
||||
"name": "clip"
|
||||
},
|
||||
@@ -16722,6 +16618,10 @@
|
||||
"use_default_template": {
|
||||
"name": "استخدام القالب الافتراضي",
|
||||
"tooltip": "استخدم القالب/الوصف المدمج في النظام إذا كان النموذج يحتوي عليه."
|
||||
},
|
||||
"video": {
|
||||
"name": "فيديو",
|
||||
"tooltip": "إطارات الفيديو كمجموعة صور. يُفترض أن يكون بمعدل ٢٤ إطارًا في الثانية؛ يتم أخذ عينة فرعية إلى ١ إطار في الثانية داخليًا."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -16905,6 +16805,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TopazVideoEnhanceV2": {
|
||||
"description": "أضف حياة جديدة للفيديو باستخدام تقنيات التكبير والاستعادة القوية.",
|
||||
"display_name": "تحسين الفيديو Topaz",
|
||||
"inputs": {
|
||||
"dynamic_compression_level": {
|
||||
"name": "مستوى الضغط الديناميكي",
|
||||
"tooltip": "مستوى CQP."
|
||||
},
|
||||
"interpolation_model": {
|
||||
"name": "نموذج الاستيفاء"
|
||||
},
|
||||
"upscaler_model": {
|
||||
"name": "نموذج التكبير"
|
||||
},
|
||||
"upscaler_model_creativity": {
|
||||
"name": "الإبداع"
|
||||
},
|
||||
"upscaler_model_prompt": {
|
||||
"name": "الموجه"
|
||||
},
|
||||
"upscaler_model_realism": {
|
||||
"name": "الواقعية"
|
||||
},
|
||||
"upscaler_model_sharp": {
|
||||
"name": "الحدة"
|
||||
},
|
||||
"upscaler_model_upscaler_resolution": {
|
||||
"name": "دقة التكبير"
|
||||
},
|
||||
"video": {
|
||||
"name": "فيديو"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TorchCompileModel": {
|
||||
"display_name": "نموذج TorchCompile",
|
||||
"inputs": {
|
||||
|
||||
@@ -1727,7 +1727,6 @@
|
||||
"Meshy": "Meshy",
|
||||
"MiniMax": "MiniMax",
|
||||
"model_specific": "model_specific",
|
||||
"Moonvalley Marey": "Moonvalley Marey",
|
||||
"OpenAI": "OpenAI",
|
||||
"Sora": "Sora",
|
||||
"cond pair": "cond pair",
|
||||
|
||||
@@ -1553,13 +1553,17 @@
|
||||
}
|
||||
},
|
||||
"ColorTransfer": {
|
||||
"display_name": "ColorTransfer",
|
||||
"display_name": "Color Transfer",
|
||||
"description": "Match the colors of one image to another using various algorithms.",
|
||||
"inputs": {
|
||||
"image_target": {
|
||||
"name": "image_target",
|
||||
"tooltip": "Image(s) to apply the color transform to."
|
||||
},
|
||||
"image_ref": {
|
||||
"name": "image_ref",
|
||||
"tooltip": "Reference image(s) to match colors to."
|
||||
},
|
||||
"method": {
|
||||
"name": "method"
|
||||
},
|
||||
@@ -1569,10 +1573,6 @@
|
||||
},
|
||||
"strength": {
|
||||
"name": "strength"
|
||||
},
|
||||
"image_ref": {
|
||||
"name": "image_ref",
|
||||
"tooltip": "Reference image(s) to match colors to. If not provided, processing is skipped"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
@@ -11489,120 +11489,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"MoonvalleyImg2VideoNode": {
|
||||
"display_name": "Moonvalley Marey Image to Video",
|
||||
"description": "Moonvalley Marey Image to Video Node",
|
||||
"inputs": {
|
||||
"image": {
|
||||
"name": "image",
|
||||
"tooltip": "The reference image used to generate the video"
|
||||
},
|
||||
"prompt": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "Negative prompt text"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution",
|
||||
"tooltip": "Resolution of the output video"
|
||||
},
|
||||
"prompt_adherence": {
|
||||
"name": "prompt_adherence",
|
||||
"tooltip": "Guidance scale for generation control"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Random seed value"
|
||||
},
|
||||
"steps": {
|
||||
"name": "steps",
|
||||
"tooltip": "Number of denoising steps"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"MoonvalleyTxt2VideoNode": {
|
||||
"display_name": "Moonvalley Marey Text to Video",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "Negative prompt text"
|
||||
},
|
||||
"resolution": {
|
||||
"name": "resolution",
|
||||
"tooltip": "Resolution of the output video"
|
||||
},
|
||||
"prompt_adherence": {
|
||||
"name": "prompt_adherence",
|
||||
"tooltip": "Guidance scale for generation control"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Random seed value"
|
||||
},
|
||||
"steps": {
|
||||
"name": "steps",
|
||||
"tooltip": "Inference steps"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"MoonvalleyVideo2VideoNode": {
|
||||
"display_name": "Moonvalley Marey Video to Video",
|
||||
"inputs": {
|
||||
"prompt": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Describes the video to generate"
|
||||
},
|
||||
"negative_prompt": {
|
||||
"name": "negative_prompt",
|
||||
"tooltip": "Negative prompt text"
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Random seed value"
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "The reference video used to generate the output video. Must be at least 5 seconds long. Videos longer than 5s will be automatically trimmed. Only MP4 format supported."
|
||||
},
|
||||
"steps": {
|
||||
"name": "steps",
|
||||
"tooltip": "Number of inference steps"
|
||||
},
|
||||
"control_type": {
|
||||
"name": "control_type"
|
||||
},
|
||||
"motion_intensity": {
|
||||
"name": "motion_intensity",
|
||||
"tooltip": "Only used if control_type is 'Motion Transfer'"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"Morphology": {
|
||||
"display_name": "ImageMorphology",
|
||||
"inputs": {
|
||||
@@ -12537,7 +12423,7 @@
|
||||
"value": {
|
||||
"name": "value"
|
||||
},
|
||||
"control_after_generate": {
|
||||
"fixed": {
|
||||
"name": "control after generate"
|
||||
}
|
||||
},
|
||||
@@ -16639,6 +16525,13 @@
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Video frames as image batch. Assumed to be 24 FPS; subsampled to 1 FPS internally."
|
||||
},
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"thinking": {
|
||||
"name": "thinking",
|
||||
"tooltip": "Operate in thinking mode if the model supports it."
|
||||
@@ -16694,6 +16587,13 @@
|
||||
"image": {
|
||||
"name": "image"
|
||||
},
|
||||
"video": {
|
||||
"name": "video",
|
||||
"tooltip": "Video frames as image batch. Assumed to be 24 FPS; subsampled to 1 FPS internally."
|
||||
},
|
||||
"audio": {
|
||||
"name": "audio"
|
||||
},
|
||||
"thinking": {
|
||||
"name": "thinking",
|
||||
"tooltip": "Operate in thinking mode if the model supports it."
|
||||
@@ -16853,7 +16753,7 @@
|
||||
}
|
||||
},
|
||||
"TopazVideoEnhance": {
|
||||
"display_name": "Topaz Video Enhance",
|
||||
"display_name": "Topaz Video Enhance (Legacy)",
|
||||
"description": "Breathe new life into video with powerful upscaling and recovery technology.",
|
||||
"inputs": {
|
||||
"video": {
|
||||
@@ -16905,6 +16805,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TopazVideoEnhanceV2": {
|
||||
"display_name": "Topaz Video Enhance",
|
||||
"description": "Breathe new life into video with powerful upscaling and recovery technology.",
|
||||
"inputs": {
|
||||
"video": {
|
||||
"name": "video"
|
||||
},
|
||||
"upscaler_model": {
|
||||
"name": "upscaler_model"
|
||||
},
|
||||
"interpolation_model": {
|
||||
"name": "interpolation_model"
|
||||
},
|
||||
"dynamic_compression_level": {
|
||||
"name": "dynamic_compression_level",
|
||||
"tooltip": "CQP level."
|
||||
},
|
||||
"upscaler_model_creativity": {
|
||||
"name": "creativity"
|
||||
},
|
||||
"upscaler_model_prompt": {
|
||||
"name": "prompt"
|
||||
},
|
||||
"upscaler_model_realism": {
|
||||
"name": "realism"
|
||||
},
|
||||
"upscaler_model_sharp": {
|
||||
"name": "sharp"
|
||||
},
|
||||
"upscaler_model_upscaler_resolution": {
|
||||
"name": "upscaler_resolution"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"TorchCompileModel": {
|
||||
"display_name": "TorchCompileModel",
|
||||
"inputs": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user