mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-30 17:25:45 +00:00
Compare commits
7 Commits
refactor/a
...
fix/save-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11553e0bc | ||
|
|
8d5e605c3a | ||
|
|
2f7f952338 | ||
|
|
3ac08fd1da | ||
|
|
f1d5337181 | ||
|
|
c289640e99 | ||
|
|
dc7c97c5ac |
47
apps/website/src/components/AcademySection.vue
Normal file
47
apps/website/src/components/AcademySection.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
const features = [
|
||||
{ icon: '📚', label: 'Guided Tutorials' },
|
||||
{ icon: '🎥', label: 'Video Courses' },
|
||||
{ icon: '🛠️', label: 'Hands-on Projects' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-3xl px-6 text-center">
|
||||
<!-- Badge -->
|
||||
<span
|
||||
class="inline-block rounded-full bg-brand-yellow/10 px-4 py-1.5 text-xs uppercase tracking-widest text-brand-yellow"
|
||||
>
|
||||
COMFY ACADEMY
|
||||
</span>
|
||||
|
||||
<h2 class="mt-6 text-3xl font-bold text-white">Master AI Workflows</h2>
|
||||
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Learn to build professional AI workflows with guided tutorials, video
|
||||
courses, and hands-on projects.
|
||||
</p>
|
||||
|
||||
<!-- Feature bullets -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-8">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature.label"
|
||||
class="flex items-center gap-2 text-sm text-white"
|
||||
>
|
||||
<span aria-hidden="true">{{ feature.icon }}</span>
|
||||
<span>{{ feature.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="/academy"
|
||||
class="mt-8 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
EXPLORE ACADEMY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
66
apps/website/src/components/CTASection.vue
Normal file
66
apps/website/src/components/CTASection.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
const cards = [
|
||||
{
|
||||
icon: '🖥️',
|
||||
title: 'Comfy Desktop',
|
||||
description: 'Full power on your local machine. Free and open source.',
|
||||
cta: 'DOWNLOAD',
|
||||
href: '/download',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '☁️',
|
||||
title: 'Comfy Cloud',
|
||||
description: 'Run workflows in the cloud. No GPU required.',
|
||||
cta: 'TRY CLOUD',
|
||||
href: 'https://app.comfy.org',
|
||||
outlined: false
|
||||
},
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Comfy API',
|
||||
description: 'Integrate AI generation into your applications.',
|
||||
cta: 'VIEW DOCS',
|
||||
href: 'https://docs.comfy.org',
|
||||
outlined: true
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-5xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
Choose Your Way to Comfy
|
||||
</h2>
|
||||
|
||||
<!-- CTA cards -->
|
||||
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<a
|
||||
v-for="card in cards"
|
||||
:key="card.title"
|
||||
:href="card.href"
|
||||
class="flex flex-1 flex-col items-center rounded-xl border border-white/10 bg-charcoal-600 p-8 text-center transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<span class="text-4xl" aria-hidden="true">{{ card.icon }}</span>
|
||||
<h3 class="mt-4 text-xl font-semibold text-white">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
<span
|
||||
class="mt-6 inline-block rounded-full px-6 py-2 text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
:class="
|
||||
card.outlined
|
||||
? 'border border-brand-yellow text-brand-yellow'
|
||||
: 'bg-brand-yellow text-black'
|
||||
"
|
||||
>
|
||||
{{ card.cta }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
77
apps/website/src/components/CaseStudySpotlight.vue
Normal file
77
apps/website/src/components/CaseStudySpotlight.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<!-- TODO: Replace placeholder content with real quotes and case studies -->
|
||||
<script setup lang="ts">
|
||||
const studies = [
|
||||
{
|
||||
title: 'New Pipelines with Chord Mode',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'md:row-span-2'
|
||||
},
|
||||
{
|
||||
title: 'AI-Assisted Texture and Environment',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'min-h-[300px] lg:col-span-2'
|
||||
},
|
||||
{
|
||||
title: 'Open-sourced the Chord Mode',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: false,
|
||||
gridClass: 'min-h-[200px]'
|
||||
},
|
||||
{
|
||||
title: 'Environment Generation',
|
||||
body: 'For AI-assisted texture and environment generation across studio pipelines.',
|
||||
highlight: true,
|
||||
gridClass: 'min-h-[200px]'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-12">
|
||||
<h2 class="text-3xl font-bold text-white">Customer Stories</h2>
|
||||
<p class="mt-2 text-smoke-700">
|
||||
See how leading studios use Comfy in production
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Bento grid -->
|
||||
<div
|
||||
class="relative grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<article
|
||||
v-for="study in studies"
|
||||
:key="study.title"
|
||||
class="flex flex-col justify-end rounded-2xl border border-brand-yellow/30 p-6"
|
||||
:class="[
|
||||
study.gridClass,
|
||||
study.highlight ? 'bg-brand-yellow' : 'bg-charcoal-600'
|
||||
]"
|
||||
>
|
||||
<h3
|
||||
class="font-semibold"
|
||||
:class="study.highlight ? 'text-black' : 'text-white'"
|
||||
>
|
||||
{{ study.title }}
|
||||
</h3>
|
||||
<p
|
||||
class="mt-2 text-sm"
|
||||
:class="study.highlight ? 'text-black/70' : 'text-smoke-700'"
|
||||
>
|
||||
{{ study.body }}
|
||||
</p>
|
||||
<a
|
||||
href="/case-studies"
|
||||
class="mt-4 text-sm underline"
|
||||
:class="study.highlight ? 'text-black' : 'text-brand-yellow'"
|
||||
>
|
||||
READ CASE STUDY
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
62
apps/website/src/components/GetStartedSection.vue
Normal file
62
apps/website/src/components/GetStartedSection.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
const steps = [
|
||||
{
|
||||
number: '1',
|
||||
title: 'Download & Sign Up',
|
||||
description: 'Get Comfy Desktop for free or create a Cloud account'
|
||||
},
|
||||
{
|
||||
number: '2',
|
||||
title: 'Load a Workflow',
|
||||
description:
|
||||
'Choose from thousands of community workflows or build your own'
|
||||
},
|
||||
{
|
||||
number: '3',
|
||||
title: 'Generate',
|
||||
description: 'Hit run and watch your AI workflow come to life'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-t border-white/10 bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6 text-center">
|
||||
<h2 class="text-3xl font-bold text-white">Get Started in Minutes</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
From download to your first AI-generated output in three simple steps
|
||||
</p>
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="mt-16 grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||
<div v-for="(step, index) in steps" :key="step.number" class="relative">
|
||||
<!-- Connecting line between steps (desktop only) -->
|
||||
<div
|
||||
v-if="index < steps.length - 1"
|
||||
class="absolute right-0 top-8 hidden w-full translate-x-1/2 border-t border-brand-yellow/20 md:block"
|
||||
/>
|
||||
|
||||
<div class="relative">
|
||||
<span class="text-6xl font-bold text-brand-yellow/20">
|
||||
{{ step.number }}
|
||||
</span>
|
||||
<h3 class="mt-2 text-xl font-semibold text-white">
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="/download"
|
||||
class="mt-12 inline-block rounded-full bg-brand-yellow px-8 py-3 text-sm font-semibold text-black transition-opacity hover:opacity-90"
|
||||
>
|
||||
DOWNLOAD COMFY
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
68
apps/website/src/components/HeroSection.vue
Normal file
68
apps/website/src/components/HeroSection.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
const ctaButtons = [
|
||||
{
|
||||
label: 'GET STARTED',
|
||||
href: 'https://app.comfy.org',
|
||||
variant: 'solid' as const
|
||||
},
|
||||
{
|
||||
label: 'LEARN MORE',
|
||||
href: '/about',
|
||||
variant: 'outline' as const
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="relative flex min-h-screen items-center overflow-hidden bg-black pt-16"
|
||||
>
|
||||
<div
|
||||
class="mx-auto flex w-full max-w-7xl flex-col items-center gap-12 px-6 md:flex-row md:gap-0"
|
||||
>
|
||||
<!-- Left: C Monogram -->
|
||||
<div class="flex w-full items-center justify-center md:w-[55%]">
|
||||
<div class="relative -ml-12 -rotate-15 md:-ml-24" aria-hidden="true">
|
||||
<div
|
||||
class="h-64 w-64 rounded-full border-[40px] border-brand-yellow md:h-[28rem] md:w-[28rem] md:border-[64px] lg:h-[36rem] lg:w-[36rem] lg:border-[80px]"
|
||||
>
|
||||
<!-- Gap on the right side to form "C" shape -->
|
||||
<div
|
||||
class="absolute right-0 top-1/2 h-32 w-24 -translate-y-1/2 translate-x-1/2 bg-black md:h-48 md:w-36 lg:h-64 lg:w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Text content -->
|
||||
<div class="flex w-full flex-col items-start md:w-[45%]">
|
||||
<h1
|
||||
class="text-5xl font-bold leading-tight tracking-tight text-white md:text-6xl lg:text-7xl"
|
||||
>
|
||||
Professional Control of Visual AI
|
||||
</h1>
|
||||
|
||||
<p class="mt-6 max-w-lg text-lg text-smoke-700">
|
||||
Comfy is the AI creation engine for visual professionals who demand
|
||||
control over every model, every parameter, and every output.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex flex-wrap gap-4">
|
||||
<a
|
||||
v-for="btn in ctaButtons"
|
||||
:key="btn.label"
|
||||
:href="btn.href"
|
||||
class="rounded-full px-8 py-3 text-sm font-semibold transition-opacity hover:opacity-90"
|
||||
:class="
|
||||
btn.variant === 'solid'
|
||||
? 'bg-brand-yellow text-black'
|
||||
: 'border border-brand-yellow text-brand-yellow'
|
||||
"
|
||||
>
|
||||
{{ btn.label }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
26
apps/website/src/components/ManifestoSection.vue
Normal file
26
apps/website/src/components/ManifestoSection.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-4xl px-6 text-center">
|
||||
<!-- Decorative quote mark -->
|
||||
<span class="text-6xl text-brand-yellow opacity-30" aria-hidden="true">
|
||||
«
|
||||
</span>
|
||||
|
||||
<h2 class="text-4xl font-bold text-white md:text-5xl">
|
||||
Method, Not Magic
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto mt-6 max-w-2xl text-lg leading-relaxed text-smoke-700">
|
||||
We believe in giving creators real control over AI. Not black boxes. Not
|
||||
magic buttons. But transparent, reproducible, node-by-node control over
|
||||
every step of the creative process.
|
||||
</p>
|
||||
|
||||
<!-- Separator line -->
|
||||
<div
|
||||
class="mx-auto mt-8 h-0.5 w-24 bg-brand-yellow opacity-30"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
51
apps/website/src/components/ProductShowcase.vue
Normal file
51
apps/website/src/components/ProductShowcase.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<!-- TODO: Replace with actual workflow demo content -->
|
||||
<script setup lang="ts">
|
||||
const features = ['Node-Based Editor', 'Real-Time Preview', 'Version Control']
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Section header -->
|
||||
<div class="text-center">
|
||||
<h2 class="text-3xl font-bold text-white">See Comfy in Action</h2>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-smoke-700">
|
||||
Watch how professionals build AI workflows with unprecedented control
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Placeholder video area -->
|
||||
<div
|
||||
class="mt-12 flex aspect-video items-center justify-center rounded-2xl border border-white/10 bg-charcoal-600"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<!-- Play button triangle -->
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-white/20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="ml-1 h-0 w-0 border-y-8 border-l-[14px] border-y-transparent border-l-white"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-smoke-700">Workflow Demo Coming Soon</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature labels -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-6">
|
||||
<div
|
||||
v-for="feature in features"
|
||||
:key="feature"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full bg-brand-yellow"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm text-smoke-700">{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
58
apps/website/src/components/SocialProofBar.vue
Normal file
58
apps/website/src/components/SocialProofBar.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
const logos = [
|
||||
'Harman',
|
||||
'Tencent',
|
||||
'Nike',
|
||||
'HP',
|
||||
'Autodesk',
|
||||
'Apple',
|
||||
'Ubisoft',
|
||||
'Lucid',
|
||||
'Amazon',
|
||||
'Netflix',
|
||||
'Pixomondo',
|
||||
'EA'
|
||||
]
|
||||
|
||||
const metrics = [
|
||||
{ value: '60K+', label: 'Custom Nodes' },
|
||||
{ value: '106K+', label: 'GitHub Stars' },
|
||||
{ value: '500K+', label: 'Community Members' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="border-y border-white/10 bg-black py-16">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<!-- Heading -->
|
||||
<p
|
||||
class="text-center text-xs font-medium uppercase tracking-widest text-smoke-700"
|
||||
>
|
||||
Trusted by Industry Leaders
|
||||
</p>
|
||||
|
||||
<!-- Logo row -->
|
||||
<div
|
||||
class="mt-10 flex flex-wrap items-center justify-center gap-4 md:gap-6"
|
||||
>
|
||||
<span
|
||||
v-for="company in logos"
|
||||
:key="company"
|
||||
class="rounded-full border border-white/10 px-6 py-2 text-sm text-smoke-700"
|
||||
>
|
||||
{{ company }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Metrics row -->
|
||||
<div
|
||||
class="mt-14 flex flex-col items-center justify-center gap-10 sm:flex-row sm:gap-12"
|
||||
>
|
||||
<div v-for="metric in metrics" :key="metric.label" class="text-center">
|
||||
<p class="text-3xl font-bold text-white">{{ metric.value }}</p>
|
||||
<p class="mt-1 text-sm text-smoke-700">{{ metric.label }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
92
apps/website/src/components/TestimonialsSection.vue
Normal file
92
apps/website/src/components/TestimonialsSection.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const activeFilter = ref('All')
|
||||
|
||||
const industries = ['All', 'VFX', 'Gaming', 'Advertising', 'Photography']
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
quote:
|
||||
'Comfy has transformed our VFX pipeline. The node-based approach gives us unprecedented control over every step of the generation process.',
|
||||
name: 'Sarah Chen',
|
||||
title: 'Lead Technical Artist',
|
||||
company: 'Studio Alpha',
|
||||
industry: 'VFX'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'The level of control over AI generation is unmatched. We can iterate on game assets faster than ever before.',
|
||||
name: 'Marcus Rivera',
|
||||
title: 'Creative Director',
|
||||
company: 'PixelForge',
|
||||
industry: 'Gaming'
|
||||
},
|
||||
{
|
||||
quote:
|
||||
'We\u2019ve cut our iteration time by 70%. Comfy workflows let our team produce high-quality creative assets at scale.',
|
||||
name: 'Yuki Tanaka',
|
||||
title: 'Head of AI',
|
||||
company: 'CreativeX',
|
||||
industry: 'Advertising'
|
||||
}
|
||||
]
|
||||
|
||||
const filteredTestimonials = computed(() => {
|
||||
if (activeFilter.value === 'All') return testimonials
|
||||
return testimonials.filter((t) => t.industry === activeFilter.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black py-24">
|
||||
<div class="mx-auto max-w-7xl px-6">
|
||||
<h2 class="text-center text-3xl font-bold text-white">
|
||||
What Professionals Say
|
||||
</h2>
|
||||
|
||||
<!-- Industry filter pills -->
|
||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
v-for="industry in industries"
|
||||
:key="industry"
|
||||
class="cursor-pointer rounded-full px-4 py-1.5 text-sm transition-colors"
|
||||
:class="
|
||||
activeFilter === industry
|
||||
? 'bg-brand-yellow text-black'
|
||||
: 'border border-white/10 text-smoke-700 hover:border-brand-yellow'
|
||||
"
|
||||
@click="activeFilter = industry"
|
||||
>
|
||||
{{ industry }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Testimonial cards -->
|
||||
<div class="mt-12 grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<article
|
||||
v-for="testimonial in filteredTestimonials"
|
||||
:key="testimonial.name"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6"
|
||||
>
|
||||
<blockquote class="text-base italic text-white">
|
||||
“{{ testimonial.quote }}”
|
||||
</blockquote>
|
||||
|
||||
<p class="mt-4 text-sm font-semibold text-white">
|
||||
{{ testimonial.name }}
|
||||
</p>
|
||||
<p class="text-sm text-smoke-700">
|
||||
{{ testimonial.title }}, {{ testimonial.company }}
|
||||
</p>
|
||||
|
||||
<span
|
||||
class="mt-3 inline-block rounded-full bg-white/5 px-2 py-0.5 text-xs text-smoke-700"
|
||||
>
|
||||
{{ testimonial.industry }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
74
apps/website/src/components/UseCaseSection.vue
Normal file
74
apps/website/src/components/UseCaseSection.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<!-- TODO: Wire category content swap when final assets arrive -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const categories = [
|
||||
'VFX & Animation',
|
||||
'Creative Agencies',
|
||||
'Gaming',
|
||||
'eCommerce & Fashion',
|
||||
'Community & Hobbyists'
|
||||
]
|
||||
|
||||
const activeCategory = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-charcoal-800 px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="flex flex-col items-center gap-12 lg:flex-row lg:gap-8">
|
||||
<!-- Left placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-full border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Center content -->
|
||||
<div class="flex flex-col items-center text-center lg:flex-[2]">
|
||||
<h2 class="text-3xl font-bold text-white">
|
||||
Built for Every Creative Industry
|
||||
</h2>
|
||||
|
||||
<nav
|
||||
class="mt-10 flex flex-col items-center gap-4"
|
||||
aria-label="Industry categories"
|
||||
>
|
||||
<button
|
||||
v-for="(category, index) in categories"
|
||||
:key="category"
|
||||
class="transition-colors"
|
||||
:class="
|
||||
index === activeCategory
|
||||
? 'text-2xl text-white'
|
||||
: 'text-xl text-ash-500 hover:text-white/70'
|
||||
"
|
||||
@click="activeCategory = index"
|
||||
>
|
||||
{{ category }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<p class="mt-10 max-w-lg text-smoke-700">
|
||||
Powered by 60,000+ nodes, thousands of workflows, and a community
|
||||
that builds faster than any one company could.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="/workflows"
|
||||
class="mt-8 rounded-full border border-brand-yellow px-8 py-3 text-sm font-semibold text-brand-yellow transition-colors hover:bg-brand-yellow hover:text-black"
|
||||
>
|
||||
EXPLORE WORKFLOWS
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Right placeholder image (desktop only) -->
|
||||
<div class="hidden flex-1 lg:block">
|
||||
<div
|
||||
class="aspect-[2/3] rounded-3xl border border-white/10 bg-charcoal-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
67
apps/website/src/components/ValuePillars.vue
Normal file
67
apps/website/src/components/ValuePillars.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
const pillars = [
|
||||
{
|
||||
icon: '⚡',
|
||||
title: 'Build',
|
||||
description:
|
||||
'Design complex AI workflows visually with our node-based editor'
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Customize',
|
||||
description: 'Fine-tune every parameter across any model architecture'
|
||||
},
|
||||
{
|
||||
icon: '🔧',
|
||||
title: 'Refine',
|
||||
description:
|
||||
'Iterate on outputs with precision controls and real-time preview'
|
||||
},
|
||||
{
|
||||
icon: '⚙️',
|
||||
title: 'Automate',
|
||||
description:
|
||||
'Scale your workflows with batch processing and API integration'
|
||||
},
|
||||
{
|
||||
icon: '🚀',
|
||||
title: 'Run',
|
||||
description: 'Deploy locally or in the cloud with identical results'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-black px-6 py-24">
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<header class="mb-16 text-center">
|
||||
<h2 class="text-3xl font-bold text-white md:text-4xl">
|
||||
The Building Blocks of AI Production
|
||||
</h2>
|
||||
<p class="mt-4 text-smoke-700">
|
||||
Five powerful capabilities that give you complete control
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<article
|
||||
v-for="pillar in pillars"
|
||||
:key="pillar.title"
|
||||
class="rounded-xl border border-white/10 bg-charcoal-600 p-6 transition-colors hover:border-brand-yellow"
|
||||
>
|
||||
<div
|
||||
class="flex h-12 w-12 items-center justify-center rounded-full bg-brand-yellow text-xl"
|
||||
>
|
||||
{{ pillar.icon }}
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-semibold text-white">
|
||||
{{ pillar.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-smoke-700">
|
||||
{{ pillar.description }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
34
apps/website/src/pages/index.astro
Normal file
34
apps/website/src/pages/index.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import SiteNav from '../components/SiteNav.vue'
|
||||
import HeroSection from '../components/HeroSection.vue'
|
||||
import SocialProofBar from '../components/SocialProofBar.vue'
|
||||
import ProductShowcase from '../components/ProductShowcase.vue'
|
||||
import ValuePillars from '../components/ValuePillars.vue'
|
||||
import UseCaseSection from '../components/UseCaseSection.vue'
|
||||
import CaseStudySpotlight from '../components/CaseStudySpotlight.vue'
|
||||
import TestimonialsSection from '../components/TestimonialsSection.vue'
|
||||
import GetStartedSection from '../components/GetStartedSection.vue'
|
||||
import CTASection from '../components/CTASection.vue'
|
||||
import ManifestoSection from '../components/ManifestoSection.vue'
|
||||
import AcademySection from '../components/AcademySection.vue'
|
||||
import SiteFooter from '../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — Professional Control of Visual AI">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
34
apps/website/src/pages/zh-CN/index.astro
Normal file
34
apps/website/src/pages/zh-CN/index.astro
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import SiteNav from '../../components/SiteNav.vue'
|
||||
import HeroSection from '../../components/HeroSection.vue'
|
||||
import SocialProofBar from '../../components/SocialProofBar.vue'
|
||||
import ProductShowcase from '../../components/ProductShowcase.vue'
|
||||
import ValuePillars from '../../components/ValuePillars.vue'
|
||||
import UseCaseSection from '../../components/UseCaseSection.vue'
|
||||
import CaseStudySpotlight from '../../components/CaseStudySpotlight.vue'
|
||||
import TestimonialsSection from '../../components/TestimonialsSection.vue'
|
||||
import GetStartedSection from '../../components/GetStartedSection.vue'
|
||||
import CTASection from '../../components/CTASection.vue'
|
||||
import ManifestoSection from '../../components/ManifestoSection.vue'
|
||||
import AcademySection from '../../components/AcademySection.vue'
|
||||
import SiteFooter from '../../components/SiteFooter.vue'
|
||||
---
|
||||
|
||||
<BaseLayout title="Comfy — 视觉 AI 的专业控制">
|
||||
<SiteNav client:load />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<SocialProofBar />
|
||||
<ProductShowcase />
|
||||
<ValuePillars />
|
||||
<UseCaseSection client:visible />
|
||||
<CaseStudySpotlight />
|
||||
<TestimonialsSection client:visible />
|
||||
<GetStartedSection />
|
||||
<CTASection />
|
||||
<ManifestoSection />
|
||||
<AcademySection />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</BaseLayout>
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { WorkspaceStore } from '../../types/globals'
|
||||
import { TestIds } from '../selectors'
|
||||
@@ -174,6 +175,8 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
super(page, 'assets')
|
||||
}
|
||||
|
||||
// --- Tab navigation ---
|
||||
|
||||
get generatedTab() {
|
||||
return this.page.getByRole('tab', { name: 'Generated' })
|
||||
}
|
||||
@@ -182,6 +185,8 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByRole('tab', { name: 'Imported' })
|
||||
}
|
||||
|
||||
// --- Empty state ---
|
||||
|
||||
get emptyStateMessage() {
|
||||
return this.page.getByText(
|
||||
'Upload files or generate content to see them here'
|
||||
@@ -192,8 +197,169 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
return this.page.getByText(title)
|
||||
}
|
||||
|
||||
// --- Search & filter ---
|
||||
|
||||
get searchInput() {
|
||||
return this.page.getByPlaceholder('Search Assets...')
|
||||
}
|
||||
|
||||
get settingsButton() {
|
||||
return this.page.getByRole('button', { name: 'View settings' })
|
||||
}
|
||||
|
||||
// --- View mode ---
|
||||
|
||||
get listViewOption() {
|
||||
return this.page.getByText('List view')
|
||||
}
|
||||
|
||||
get gridViewOption() {
|
||||
return this.page.getByText('Grid view')
|
||||
}
|
||||
|
||||
// --- Sort options (cloud-only, shown inside settings popover) ---
|
||||
|
||||
get sortNewestFirst() {
|
||||
return this.page.getByText('Newest first')
|
||||
}
|
||||
|
||||
get sortOldestFirst() {
|
||||
return this.page.getByText('Oldest first')
|
||||
}
|
||||
|
||||
// --- Asset cards ---
|
||||
|
||||
get assetCards() {
|
||||
return this.page.locator('[role="button"][data-selected]')
|
||||
}
|
||||
|
||||
getAssetCardByName(name: string) {
|
||||
return this.page.locator('[role="button"][data-selected]', {
|
||||
hasText: name
|
||||
})
|
||||
}
|
||||
|
||||
get selectedCards() {
|
||||
return this.page.locator('[data-selected="true"]')
|
||||
}
|
||||
|
||||
// --- List view items ---
|
||||
|
||||
get listViewItems() {
|
||||
return this.page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
}
|
||||
|
||||
// --- Selection footer ---
|
||||
|
||||
get selectionFooter() {
|
||||
return this.page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
}
|
||||
|
||||
get selectionCountButton() {
|
||||
return this.page.getByText(/Assets Selected: \d+/)
|
||||
}
|
||||
|
||||
get deselectAllButton() {
|
||||
return this.page.getByText('Deselect all')
|
||||
}
|
||||
|
||||
get deleteSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
get downloadSelectedButton() {
|
||||
return this.page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(this.page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
}
|
||||
|
||||
// --- Context menu ---
|
||||
|
||||
contextMenuItem(label: string) {
|
||||
return this.page.locator('.p-contextmenu').getByText(label)
|
||||
}
|
||||
|
||||
// --- Folder view ---
|
||||
|
||||
get backToAssetsButton() {
|
||||
return this.page.getByText('Back to all assets')
|
||||
}
|
||||
|
||||
// --- Loading ---
|
||||
|
||||
get skeletonLoaders() {
|
||||
return this.page.locator('.sidebar-content-container .animate-pulse')
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
override async open() {
|
||||
// Remove any toast notifications that may overlay the sidebar button
|
||||
await this.dismissToasts()
|
||||
await super.open()
|
||||
await this.generatedTab.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
/** Dismiss all visible toast notifications by clicking their close buttons. */
|
||||
async dismissToasts() {
|
||||
const closeButtons = this.page.locator('.p-toast-close-button')
|
||||
for (const btn of await closeButtons.all()) {
|
||||
await btn.click({ force: true }).catch(() => {})
|
||||
}
|
||||
// Wait for all toast elements to fully animate out and detach from DOM
|
||||
await expect(this.page.locator('.p-toast-message'))
|
||||
.toHaveCount(0, { timeout: 5000 })
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
async switchToImported() {
|
||||
await this.dismissToasts()
|
||||
await this.importedTab.click()
|
||||
await expect(this.importedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async switchToGenerated() {
|
||||
await this.dismissToasts()
|
||||
await this.generatedTab.click()
|
||||
await expect(this.generatedTab).toHaveAttribute('aria-selected', 'true', {
|
||||
timeout: 3000
|
||||
})
|
||||
}
|
||||
|
||||
async openSettingsMenu() {
|
||||
await this.dismissToasts()
|
||||
await this.settingsButton.click()
|
||||
// Wait for popover content to render
|
||||
await this.listViewOption
|
||||
.or(this.gridViewOption)
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async rightClickAsset(name: string) {
|
||||
const card = this.getAssetCardByName(name)
|
||||
await card.click({ button: 'right' })
|
||||
await this.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
}
|
||||
|
||||
async waitForAssets(count?: number) {
|
||||
if (count !== undefined) {
|
||||
await expect(this.assetCards).toHaveCount(count, { timeout: 5000 })
|
||||
} else {
|
||||
await this.assetCards.first().waitFor({ state: 'visible', timeout: 5000 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,63 @@ import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/j
|
||||
const jobsListRoutePattern = /\/api\/jobs(?:\?.*)?$/
|
||||
const inputFilesRoutePattern = /\/internal\/files\/input(?:\?.*)?$/
|
||||
|
||||
/** Factory to create a mock completed job with preview output. */
|
||||
export function createMockJob(
|
||||
overrides: Partial<RawJobListItem> & { id: string }
|
||||
): RawJobListItem {
|
||||
const now = Date.now() / 1000
|
||||
return {
|
||||
status: 'completed',
|
||||
create_time: now,
|
||||
execution_start_time: now,
|
||||
execution_end_time: now + 5,
|
||||
preview_output: {
|
||||
filename: `output_${overrides.id}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1,
|
||||
priority: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** Create multiple mock jobs with sequential IDs and staggered timestamps. */
|
||||
export function createMockJobs(
|
||||
count: number,
|
||||
baseOverrides?: Partial<RawJobListItem>
|
||||
): RawJobListItem[] {
|
||||
const now = Date.now() / 1000
|
||||
return Array.from({ length: count }, (_, i) =>
|
||||
createMockJob({
|
||||
id: `job-${String(i + 1).padStart(3, '0')}`,
|
||||
create_time: now - i * 60,
|
||||
execution_start_time: now - i * 60,
|
||||
execution_end_time: now - i * 60 + 5 + i,
|
||||
preview_output: {
|
||||
filename: `image_${String(i + 1).padStart(3, '0')}.png`,
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
...baseOverrides
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Create mock imported file names with various media types. */
|
||||
export function createMockImportedFiles(count: number): string[] {
|
||||
const extensions = ['png', 'jpg', 'mp4', 'wav', 'glb', 'txt']
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) =>
|
||||
`imported_${String(i + 1).padStart(3, '0')}.${extensions[i % extensions.length]}`
|
||||
)
|
||||
}
|
||||
|
||||
function parseLimit(url: URL, total: number): number {
|
||||
const value = Number(url.searchParams.get('limit'))
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
|
||||
@@ -1,8 +1,72 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
import {
|
||||
createMockJob,
|
||||
createMockJobs
|
||||
} from '../../fixtures/helpers/AssetsHelper'
|
||||
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
|
||||
|
||||
test.describe('Assets sidebar', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
create_time: 1000,
|
||||
execution_start_time: 1000,
|
||||
execution_end_time: 1010,
|
||||
preview_output: {
|
||||
filename: 'landscape.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '1',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-beta',
|
||||
create_time: 2000,
|
||||
execution_start_time: 2000,
|
||||
execution_end_time: 2003,
|
||||
preview_output: {
|
||||
filename: 'portrait.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '2',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 1
|
||||
}),
|
||||
createMockJob({
|
||||
id: 'job-gamma',
|
||||
create_time: 3000,
|
||||
execution_start_time: 3000,
|
||||
execution_end_time: 3020,
|
||||
preview_output: {
|
||||
filename: 'abstract_art.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
nodeId: '3',
|
||||
mediaType: 'images'
|
||||
},
|
||||
outputs_count: 2
|
||||
})
|
||||
]
|
||||
|
||||
const SAMPLE_IMPORTED_FILES = [
|
||||
'reference_photo.png',
|
||||
'background.jpg',
|
||||
'audio_clip.wav'
|
||||
]
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Empty states
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - empty states', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockEmptyState()
|
||||
await comfyPage.setup()
|
||||
@@ -12,19 +76,587 @@ test.describe('Assets sidebar', () => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Shows empty-state copy for generated and imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test('Shows empty-state copy for generated tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.emptyStateTitle('No generated files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
await tab.importedTab.click()
|
||||
test('Shows empty-state copy for imported tab', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
await expect(tab.emptyStateTitle('No imported files found')).toBeVisible()
|
||||
await expect(tab.emptyStateMessage).toBeVisible()
|
||||
})
|
||||
|
||||
test('No asset cards are rendered when empty', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.assetCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Tab navigation
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - tab navigation', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Generated tab is active by default', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
test('Can switch between Generated and Imported tabs', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
test('Search is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Asset display - grid view
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - grid view display', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles(SAMPLE_IMPORTED_FILES)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Displays generated assets as cards in grid view', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.waitForAssets()
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
test('Displays imported files when switching to Imported tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. View mode toggle (grid <-> list)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - view mode toggle', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Can switch to list view via settings menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Can switch back to grid view', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Search functionality
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - search', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Search input is visible', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await expect(tab.searchInput).toBeVisible()
|
||||
})
|
||||
|
||||
test('Filtering assets by search query reduces displayed count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect(async () => {
|
||||
const filteredCount = await tab.assetCards.count()
|
||||
expect(filteredCount).toBeLessThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Clearing search restores all assets', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(async () => {
|
||||
expect(await tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await tab.searchInput.fill('')
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('Search with no matches shows empty state', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.searchInput.fill('nonexistent_file_xyz')
|
||||
await expect(tab.assetCards).toHaveCount(0, { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 6. Asset selection
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking an asset card selects it', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
test('Ctrl+click adds to selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
|
||||
test('Selection shows footer with count and actions', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Deselect all clears selection', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('Selection is cleared when switching tabs', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 7. Context menu
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - context menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Right-clicking an asset shows context menu', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Context menu contains Download action for output asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Download')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Inspect action for image assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Inspect asset')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Delete action for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Delete')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains Copy job ID for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await comfyPage.page
|
||||
.locator('.p-contextmenu')
|
||||
.waitFor({ state: 'visible', timeout: 3000 })
|
||||
|
||||
await expect(tab.contextMenuItem('Copy job ID')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Context menu contains workflow actions for output assets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible({ timeout: 3000 })
|
||||
|
||||
await expect(
|
||||
tab.contextMenuItem('Open as workflow in new tab')
|
||||
).toBeVisible()
|
||||
await expect(tab.contextMenuItem('Export workflow')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bulk context menu shows when multiple assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: click first, then Ctrl/Cmd+click second
|
||||
await cards.first().click()
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2, { timeout: 3000 })
|
||||
await expect(tab.selectionFooter).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Right-click on a selected card (retry to let grid layout settle)
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(async () => {
|
||||
await cards.first().click({ button: 'right' })
|
||||
await expect(contextMenu).toBeVisible()
|
||||
}).toPass({ intervals: [300], timeout: 5000 })
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 8. Bulk actions (footer)
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - bulk actions', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Footer shows download button when assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Footer shows delete button when output assets selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('Selection count displays correct number', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Select two assets
|
||||
const cards = tab.assetCards
|
||||
const cardCount = await cards.count()
|
||||
expect(cardCount).toBeGreaterThanOrEqual(2)
|
||||
|
||||
await cards.first().click()
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible({ timeout: 3000 })
|
||||
const text = await tab.selectionCountButton.textContent()
|
||||
expect(text).toMatch(/Assets Selected: \d+/)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 9. Pagination
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - pagination', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Initially loads a batch of assets with has_more pagination', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Create a large set of jobs to trigger pagination
|
||||
const manyJobs = createMockJobs(30)
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Should load at least the first batch
|
||||
const count = await tab.assetCards.count()
|
||||
expect(count).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 10. Settings menu visibility
|
||||
// ==========================================================================
|
||||
|
||||
test.describe('Assets sidebar - settings menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.mockOutputHistory(SAMPLE_JOBS)
|
||||
await comfyPage.assets.mockInputFiles([])
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.assets.clearMocks()
|
||||
})
|
||||
|
||||
test('Settings menu shows view mode options', async ({ comfyPage }) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.openSettingsMenu()
|
||||
|
||||
await expect(tab.listViewOption).toBeVisible()
|
||||
await expect(tab.gridViewOption).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -323,6 +323,174 @@ test.describe('Workflow Persistence', () => {
|
||||
expect(linkCountAfter).toBe(linkCountBefore)
|
||||
})
|
||||
|
||||
test('Closing an inactive tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const suffix = Date.now().toString(36)
|
||||
const nameA = `test-A-${suffix}`
|
||||
const nameB = `test-B-${suffix}`
|
||||
|
||||
// Save the default workflow as A
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameA)
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Create B: duplicate and save
|
||||
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameB)
|
||||
|
||||
// Add a Note node in B to mark it as modified
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountB).toBe(nodeCountA + 1)
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
// Switch to A via topbar tab (making B inactive)
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Close inactive B tab via middle-click — triggers "Save before closing?"
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameB).click({
|
||||
button: 'middle'
|
||||
})
|
||||
|
||||
// Click "Save" in the dirty close dialog
|
||||
const saveButton = comfyPage.page.getByRole('button', { name: 'Save' })
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify we're still on A with A's content
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Re-open B from sidebar saved list
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(nameB).dblclick()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
// B should have the extra Note node we added, not A's node count
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Closing an inactive unsaved tab with save preserves its own content', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
|
||||
const suffix = Date.now().toString(36)
|
||||
const nameA = `test-A-${suffix}`
|
||||
const nameB = `test-B-${suffix}`
|
||||
|
||||
// Save the default workflow as A
|
||||
await comfyPage.menu.topbar.saveWorkflow(nameA)
|
||||
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Create B as an unsaved workflow with a Note node
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
expect(nodeCountB).toBe(1)
|
||||
expect(nodeCountA).not.toBe(nodeCountB)
|
||||
|
||||
// Switch to A via topbar tab (making unsaved B inactive)
|
||||
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Close inactive unsaved B tab — triggers "Save before closing?"
|
||||
await comfyPage.menu.topbar
|
||||
.getWorkflowTab('Unsaved Workflow')
|
||||
.click({ button: 'middle' })
|
||||
|
||||
// Click "Save" in the dirty close dialog (scoped to dialog)
|
||||
const dialog = comfyPage.page.getByRole('dialog')
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.waitFor({ state: 'visible' })
|
||||
await saveButton.click()
|
||||
|
||||
// Fill in the filename dialog
|
||||
const saveDialog = comfyPage.menu.topbar.getSaveDialog()
|
||||
await saveDialog.waitFor({ state: 'visible' })
|
||||
await saveDialog.fill(nameB)
|
||||
await comfyPage.page.keyboard.press('Enter')
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Verify we're still on A with A's content
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
|
||||
.toBe(nodeCountA)
|
||||
|
||||
// Re-open B from sidebar saved list
|
||||
const workflowsTab = comfyPage.menu.workflowsTab
|
||||
await workflowsTab.open()
|
||||
await workflowsTab.getPersistedItem(nameB).dblclick()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
// B should have 1 node (the Note), not A's node count
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
|
||||
.toBe(nodeCountB)
|
||||
})
|
||||
|
||||
test('Splitter panel sizes persist correctly in localStorage', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.43.9",
|
||||
"version": "1.43.10",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -192,3 +192,15 @@ export function curvesToLUT(
|
||||
|
||||
return lut
|
||||
}
|
||||
|
||||
export function curveDataToFloatLUT(
|
||||
curve: CurveData,
|
||||
size: number = 256
|
||||
): Float32Array {
|
||||
const lut = new Float32Array(size)
|
||||
const interpolate = createInterpolator(curve.points, curve.interpolation)
|
||||
for (let i = 0; i < size; i++) {
|
||||
lut[i] = interpolate(i / (size - 1))
|
||||
}
|
||||
return lut
|
||||
}
|
||||
|
||||
@@ -143,11 +143,16 @@
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
size="icon"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button size="icon" @click="handleDownloadSelected">
|
||||
<Button
|
||||
size="icon"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
@@ -156,12 +161,17 @@
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
variant="secondary"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" @click="handleDownloadSelected">
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
|
||||
@@ -15680,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "صورة UV",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv_image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "imagen UV",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "تصویر UV",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv_image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv画像",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv_image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "imagem UV",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv_image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv_görüntüsü",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv_image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -15680,6 +15680,10 @@
|
||||
"1": {
|
||||
"name": "FBX",
|
||||
"tooltip": null
|
||||
},
|
||||
"2": {
|
||||
"name": "uv_image",
|
||||
"tooltip": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,7 +139,7 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
|
||||
if (isSelfOverwrite) {
|
||||
workflow.changeTracker?.checkState()
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
await saveWorkflow(workflow)
|
||||
} else {
|
||||
let target: ComfyWorkflow
|
||||
@@ -156,7 +156,7 @@ export const useWorkflowService = () => {
|
||||
app.rootGraph.extra.linearMode = isApp
|
||||
target.initialMode = isApp ? 'app' : 'graph'
|
||||
}
|
||||
target.changeTracker?.checkState()
|
||||
if (workflowStore.isActive(target)) target.changeTracker?.checkState()
|
||||
|
||||
await workflowStore.saveWorkflow(target)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export const useWorkflowService = () => {
|
||||
if (workflow.isTemporary) {
|
||||
await saveWorkflowAs(workflow)
|
||||
} else {
|
||||
workflow.changeTracker?.checkState()
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
|
||||
const isApp = workflow.initialMode === 'app'
|
||||
const expectedPath =
|
||||
|
||||
@@ -284,6 +284,7 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useGLSLPreview } from '@/renderer/glsl/useGLSLPreview'
|
||||
import { usePromotedPreviews } from '@/composables/node/usePromotedPreviews'
|
||||
import NodeBadges from '@/renderer/extensions/vueNodes/components/NodeBadges.vue'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
@@ -730,6 +731,8 @@ const lgraphNode = computed(() => {
|
||||
// reaching through lgraphNode for promoted preview resolution.
|
||||
const { promotedPreviews } = usePromotedPreviews(lgraphNode)
|
||||
|
||||
useGLSLPreview(lgraphNode)
|
||||
|
||||
const showAdvancedInputsButton = computed(() => {
|
||||
const node = lgraphNode.value
|
||||
if (!node) return false
|
||||
|
||||
40
src/renderer/glsl/glslPreviewUtils.ts
Normal file
40
src/renderer/glsl/glslPreviewUtils.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
|
||||
export const GLSL_NODE_TYPE = 'GLSLShader'
|
||||
export const DEBOUNCE_MS = 50
|
||||
export const DEFAULT_SIZE = 512
|
||||
const MAX_PREVIEW_DIMENSION = 1024
|
||||
|
||||
export function normalizeDimension(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_SIZE
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function clampResolution(w: number, h: number): [number, number] {
|
||||
const maxDim = Math.max(w, h)
|
||||
if (maxDim <= MAX_PREVIEW_DIMENSION) return [w, h]
|
||||
const scale = MAX_PREVIEW_DIMENSION / maxDim
|
||||
return [Math.round(w * scale), Math.round(h * scale)]
|
||||
}
|
||||
|
||||
export function getImageThroughSubgraphBoundary(
|
||||
node: LGraphNode,
|
||||
slot: number,
|
||||
ownerSubgraphNode: LGraphNode
|
||||
): HTMLImageElement | undefined {
|
||||
const graph = node.graph
|
||||
if (!graph) return undefined
|
||||
|
||||
const input = node.inputs[slot]
|
||||
if (input?.link == null) return undefined
|
||||
|
||||
const link = graph._links.get(input.link)
|
||||
if (!link || link.origin_id !== SUBGRAPH_INPUT_ID) return undefined
|
||||
|
||||
const outerUpstream = ownerSubgraphNode.getInputNode(link.origin_slot)
|
||||
if (!outerUpstream?.imgs?.length) return undefined
|
||||
|
||||
return outerUpstream.imgs[0]
|
||||
}
|
||||
331
src/renderer/glsl/useGLSLPreview.test.ts
Normal file
331
src/renderer/glsl/useGLSLPreview.test.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive, ref, shallowRef } from 'vue'
|
||||
|
||||
import { useGLSLPreview } from '@/renderer/glsl/useGLSLPreview'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const mockRendererFactory = vi.hoisted(() => {
|
||||
const init = vi.fn(() => true)
|
||||
const compileFragment = vi.fn(() => ({ success: true, log: '' }))
|
||||
const setResolution = vi.fn()
|
||||
const setFloatUniform = vi.fn()
|
||||
const setIntUniform = vi.fn()
|
||||
const setBoolUniform = vi.fn()
|
||||
const bindCurveTexture = vi.fn()
|
||||
const bindInputImage = vi.fn()
|
||||
const render = vi.fn()
|
||||
const toBlob = vi.fn(() => Promise.resolve(new Blob(['test'])))
|
||||
const dispose = vi.fn()
|
||||
const lastConfig = { value: undefined as GLSLRendererConfig | undefined }
|
||||
|
||||
return {
|
||||
create: (config?: GLSLRendererConfig) => {
|
||||
lastConfig.value = config
|
||||
return {
|
||||
init,
|
||||
compileFragment,
|
||||
setResolution,
|
||||
setFloatUniform,
|
||||
setIntUniform,
|
||||
setBoolUniform,
|
||||
bindCurveTexture,
|
||||
bindInputImage,
|
||||
render,
|
||||
toBlob,
|
||||
dispose
|
||||
}
|
||||
},
|
||||
lastConfig,
|
||||
init,
|
||||
compileFragment,
|
||||
setResolution,
|
||||
setFloatUniform,
|
||||
setIntUniform,
|
||||
setBoolUniform,
|
||||
bindCurveTexture,
|
||||
bindInputImage,
|
||||
render,
|
||||
toBlob,
|
||||
dispose
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/renderer/glsl/useGLSLRenderer', () => ({
|
||||
useGLSLRenderer: (config?: GLSLRendererConfig) =>
|
||||
mockRendererFactory.create(config)
|
||||
}))
|
||||
|
||||
const mockSetNodePreviewsByNodeId = vi.fn()
|
||||
const mockNodeOutputs = reactive<Record<string, unknown>>({})
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: () => ({
|
||||
setNodePreviewsByNodeId: mockSetNodePreviewsByNodeId,
|
||||
setNodePreviewsByLocatorId: vi.fn(),
|
||||
revokePreviewsByLocatorId: vi.fn(),
|
||||
nodeOutputs: mockNodeOutputs
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => {
|
||||
const widgetMap = new Map<string, { value: unknown }>()
|
||||
const getWidget = vi.fn((_graphId: string, _nodeId: string, name: string) =>
|
||||
widgetMap.get(name)
|
||||
)
|
||||
return {
|
||||
useWidgetValueStore: () => ({
|
||||
getWidget,
|
||||
_widgetMap: widgetMap
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
nodeIdToNodeLocatorId: (id: string | number) => String(id),
|
||||
nodeToNodeLocatorId: (node: { id: string | number }) => String(node.id)
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/objectUrlUtil', () => ({
|
||||
createSharedObjectUrl: () => 'blob:test',
|
||||
releaseSharedObjectUrl: vi.fn()
|
||||
}))
|
||||
|
||||
function createMockNode(overrides: Record<string, unknown> = {}): LGraphNode {
|
||||
const graph = { id: 'test-graph-id', rootGraph: { id: 'test-graph-id' } }
|
||||
return {
|
||||
id: 1,
|
||||
type: 'GLSLShader',
|
||||
inputs: [],
|
||||
graph,
|
||||
getInputNode: vi.fn(() => null),
|
||||
isSubgraphNode: () => false,
|
||||
...overrides
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
function wrapNode(
|
||||
node: LGraphNode | null
|
||||
): MaybeRefOrGetter<LGraphNode | null> {
|
||||
return ref(node) as MaybeRefOrGetter<LGraphNode | null>
|
||||
}
|
||||
|
||||
describe('useGLSLPreview', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockRendererFactory.lastConfig.value = undefined
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:test')
|
||||
globalThis.URL.revokeObjectURL = vi.fn()
|
||||
})
|
||||
|
||||
it('does not activate for non-GLSLShader nodes', () => {
|
||||
const node = createMockNode({ type: 'KSampler' })
|
||||
const { isActive } = useGLSLPreview(wrapNode(node))
|
||||
expect(isActive.value).toBe(false)
|
||||
})
|
||||
|
||||
it('does not activate before first execution', () => {
|
||||
const node = createMockNode()
|
||||
Object.keys(mockNodeOutputs).forEach((k) => delete mockNodeOutputs[k])
|
||||
const { isActive } = useGLSLPreview(wrapNode(node))
|
||||
expect(isActive.value).toBe(false)
|
||||
})
|
||||
|
||||
it('activates for GLSLShader nodes with execution output', () => {
|
||||
const node = createMockNode()
|
||||
mockNodeOutputs['1'] = {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
|
||||
}
|
||||
const { isActive } = useGLSLPreview(wrapNode(node))
|
||||
expect(isActive.value).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes lastError as null initially', () => {
|
||||
const node = createMockNode()
|
||||
const { lastError } = useGLSLPreview(wrapNode(node))
|
||||
expect(lastError.value).toBe(null)
|
||||
})
|
||||
|
||||
it('does not activate for null node', () => {
|
||||
const { isActive } = useGLSLPreview(wrapNode(null))
|
||||
expect(isActive.value).toBe(false)
|
||||
})
|
||||
|
||||
it('cleans up on dispose', () => {
|
||||
const node = createMockNode()
|
||||
const { dispose } = useGLSLPreview(wrapNode(node))
|
||||
expect(() => dispose()).not.toThrow()
|
||||
})
|
||||
|
||||
describe('autogrow config extraction', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function triggerRender(node: LGraphNode) {
|
||||
mockNodeOutputs[String(node.id)] = {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
|
||||
}
|
||||
const store = useWidgetValueStore() as unknown as {
|
||||
_widgetMap: Map<string, { value: unknown }>
|
||||
}
|
||||
store._widgetMap.set('fragment_shader', {
|
||||
value: 'void main() {}'
|
||||
})
|
||||
|
||||
const nodeRef = shallowRef<LGraphNode | null>(null)
|
||||
useGLSLPreview(nodeRef)
|
||||
|
||||
nodeRef.value = node
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(100)
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
it('passes default config when node has no comfyDynamic', async () => {
|
||||
const node = createMockNode()
|
||||
await triggerRender(node)
|
||||
|
||||
expect(mockRendererFactory.lastConfig.value).toEqual({
|
||||
maxInputs: 5,
|
||||
maxFloatUniforms: 20,
|
||||
maxIntUniforms: 20,
|
||||
maxBoolUniforms: 10,
|
||||
maxCurves: 4
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts autogrow limits from node comfyDynamic', async () => {
|
||||
const node = createMockNode({
|
||||
comfyDynamic: {
|
||||
autogrow: {
|
||||
images: { min: 1, max: 3 },
|
||||
floats: { min: 0, max: 8 },
|
||||
ints: { min: 0, max: 4 }
|
||||
}
|
||||
}
|
||||
})
|
||||
await triggerRender(node)
|
||||
|
||||
expect(mockRendererFactory.lastConfig.value).toEqual({
|
||||
maxInputs: 3,
|
||||
maxFloatUniforms: 8,
|
||||
maxIntUniforms: 4,
|
||||
maxBoolUniforms: 10,
|
||||
maxCurves: 4
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('render pipeline', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function setupAndRender(node: LGraphNode) {
|
||||
mockNodeOutputs[String(node.id)] = {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
|
||||
}
|
||||
const store = useWidgetValueStore() as unknown as {
|
||||
_widgetMap: Map<string, { value: unknown }>
|
||||
}
|
||||
store._widgetMap.set('fragment_shader', {
|
||||
value: 'void main() {}'
|
||||
})
|
||||
|
||||
const nodeRef = shallowRef<LGraphNode | null>(null)
|
||||
const result = useGLSLPreview(nodeRef)
|
||||
|
||||
nodeRef.value = node
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(100)
|
||||
await nextTick()
|
||||
// Allow async renderPreview to complete
|
||||
await nextTick()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
it('calls compileFragment, render, and toBlob in sequence', async () => {
|
||||
const node = createMockNode()
|
||||
await setupAndRender(node)
|
||||
|
||||
expect(mockRendererFactory.compileFragment).toHaveBeenCalledWith(
|
||||
'void main() {}'
|
||||
)
|
||||
expect(mockRendererFactory.render).toHaveBeenCalled()
|
||||
expect(mockRendererFactory.toBlob).toHaveBeenCalled()
|
||||
|
||||
const compileOrder =
|
||||
mockRendererFactory.compileFragment.mock.invocationCallOrder[0]
|
||||
const renderOrder = mockRendererFactory.render.mock.invocationCallOrder[0]
|
||||
const toBlobOrder = mockRendererFactory.toBlob.mock.invocationCallOrder[0]
|
||||
expect(compileOrder).toBeLessThan(renderOrder)
|
||||
expect(renderOrder).toBeLessThan(toBlobOrder)
|
||||
})
|
||||
|
||||
it('sets lastError on compilation failure', async () => {
|
||||
mockRendererFactory.compileFragment.mockReturnValueOnce({
|
||||
success: false,
|
||||
log: 'syntax error at line 5'
|
||||
})
|
||||
|
||||
const node = createMockNode()
|
||||
const { lastError } = await setupAndRender(node)
|
||||
|
||||
expect(lastError.value).toBe('syntax error at line 5')
|
||||
})
|
||||
|
||||
it('clears lastError on successful compilation', async () => {
|
||||
const node = createMockNode()
|
||||
const { lastError } = await setupAndRender(node)
|
||||
|
||||
expect(lastError.value).toBe(null)
|
||||
})
|
||||
|
||||
it('skips render when shader source is unavailable', async () => {
|
||||
const store = useWidgetValueStore() as unknown as {
|
||||
_widgetMap: Map<string, { value: unknown }>
|
||||
}
|
||||
store._widgetMap.delete('fragment_shader')
|
||||
|
||||
const node = createMockNode()
|
||||
mockNodeOutputs[String(node.id)] = {
|
||||
images: [{ filename: 'test.png', subfolder: '', type: 'temp' }]
|
||||
}
|
||||
|
||||
const nodeRef = shallowRef<LGraphNode | null>(null)
|
||||
useGLSLPreview(nodeRef)
|
||||
nodeRef.value = node
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(100)
|
||||
await nextTick()
|
||||
|
||||
expect(mockRendererFactory.compileFragment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disposes renderer and cancels debounce on cleanup', async () => {
|
||||
const node = createMockNode()
|
||||
const { dispose } = await setupAndRender(node)
|
||||
|
||||
dispose()
|
||||
|
||||
expect(mockRendererFactory.dispose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
500
src/renderer/glsl/useGLSLPreview.ts
Normal file
500
src/renderer/glsl/useGLSLPreview.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import { computed, effectScope, onScopeDispose, ref, toValue, watch } from 'vue'
|
||||
|
||||
import type { ComputedRef, EffectScope, MaybeRefOrGetter, Ref } from 'vue'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { curveDataToFloatLUT } from '@/components/curve/curveUtils'
|
||||
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
|
||||
import { useGLSLRenderer } from '@/renderer/glsl/useGLSLRenderer'
|
||||
import {
|
||||
extractUniformSources,
|
||||
getAutogrowLimits,
|
||||
useGLSLUniforms
|
||||
} from '@/renderer/glsl/useGLSLUniforms'
|
||||
import {
|
||||
createSharedObjectUrl,
|
||||
releaseSharedObjectUrl
|
||||
} from '@/utils/objectUrlUtil'
|
||||
|
||||
import {
|
||||
clampResolution,
|
||||
DEBOUNCE_MS,
|
||||
DEFAULT_SIZE,
|
||||
getImageThroughSubgraphBoundary,
|
||||
GLSL_NODE_TYPE,
|
||||
normalizeDimension
|
||||
} from '@/renderer/glsl/glslPreviewUtils'
|
||||
|
||||
/**
|
||||
* Two-tier composable for GLSL live preview.
|
||||
*
|
||||
* Outer tier (always created): only 2 cheap computed refs to detect
|
||||
* whether the node is GLSL-related. For non-GLSL nodes this is the
|
||||
* only cost — no watchers, store subscriptions, or renderer.
|
||||
*
|
||||
* Inner tier (lazy): created via effectScope when the node is detected
|
||||
* as a GLSLShader or a subgraph containing one. Contains all the
|
||||
* expensive logic: store reads, watchers, debounce, WebGL renderer.
|
||||
*/
|
||||
export function useGLSLPreview(
|
||||
nodeMaybe: MaybeRefOrGetter<LGraphNode | null | undefined>
|
||||
) {
|
||||
const lastError = ref<string | null>(null)
|
||||
|
||||
const nodeRef = computed(() => toValue(nodeMaybe) ?? null)
|
||||
|
||||
const isGLSLNode = computed(() => nodeRef.value?.type === GLSL_NODE_TYPE)
|
||||
|
||||
const isGLSLSubgraphNode = computed(() => {
|
||||
const node = nodeRef.value
|
||||
if (!node?.isSubgraphNode()) return false
|
||||
const subgraph = node.subgraph as Subgraph | undefined
|
||||
return subgraph?.nodes.some((n) => n.type === GLSL_NODE_TYPE) ?? false
|
||||
})
|
||||
|
||||
const isGLSLRelated = computed(
|
||||
() => isGLSLNode.value || isGLSLSubgraphNode.value
|
||||
)
|
||||
|
||||
let innerScope: EffectScope | null = null
|
||||
let innerDispose: (() => void) | null = null
|
||||
const isActive = ref(false)
|
||||
|
||||
watch(
|
||||
isGLSLRelated,
|
||||
(related) => {
|
||||
if (related && !innerScope) {
|
||||
innerScope = effectScope()
|
||||
innerDispose = innerScope.run(() =>
|
||||
createInnerPreview(
|
||||
nodeRef,
|
||||
isGLSLNode,
|
||||
isGLSLSubgraphNode,
|
||||
lastError,
|
||||
isActive
|
||||
)
|
||||
)!
|
||||
} else if (!related && innerScope) {
|
||||
innerDispose?.()
|
||||
innerScope.stop()
|
||||
innerScope = null
|
||||
innerDispose = null
|
||||
isActive.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onScopeDispose(() => {
|
||||
innerDispose?.()
|
||||
innerScope?.stop()
|
||||
})
|
||||
|
||||
return {
|
||||
isActive: computed(() => isActive.value),
|
||||
lastError,
|
||||
dispose() {
|
||||
innerDispose?.()
|
||||
innerScope?.stop()
|
||||
innerScope = null
|
||||
innerDispose = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner tier: all expensive GLSL preview logic.
|
||||
* Runs inside its own effectScope so it can be created/destroyed
|
||||
* independently of the component lifecycle.
|
||||
* Returns a dispose function.
|
||||
*/
|
||||
function createInnerPreview(
|
||||
nodeRef: ComputedRef<LGraphNode | null>,
|
||||
isGLSLNode: ComputedRef<boolean>,
|
||||
isGLSLSubgraphNode: ComputedRef<boolean>,
|
||||
lastError: Ref<string | null>,
|
||||
isActiveOut: Ref<boolean>
|
||||
): () => void {
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const { nodeToNodeLocatorId } = useWorkflowStore()
|
||||
|
||||
let renderer: ReturnType<typeof useGLSLRenderer> | null = null
|
||||
let rendererReady = false
|
||||
let renderRequestId = 0
|
||||
|
||||
const innerGLSLNode = (() => {
|
||||
const node = nodeRef.value
|
||||
if (!node?.isSubgraphNode()) return null
|
||||
const subgraph = node.subgraph as Subgraph | undefined
|
||||
return subgraph?.nodes.find((n) => n.type === GLSL_NODE_TYPE) ?? null
|
||||
})()
|
||||
|
||||
const ownerSubgraphNode = (() => {
|
||||
const node = nodeRef.value
|
||||
const graph = node?.graph
|
||||
if (!graph) return null
|
||||
const rootGraph = graph.rootGraph
|
||||
if (!rootGraph || graph === rootGraph) return null
|
||||
|
||||
return (
|
||||
rootGraph._nodes?.find(
|
||||
(n) => n.isSubgraphNode() && n.subgraph === graph
|
||||
) ?? null
|
||||
)
|
||||
})()
|
||||
|
||||
const graphId = computed(
|
||||
() => nodeRef.value?.graph?.rootGraph?.id as UUID | undefined
|
||||
)
|
||||
|
||||
const nodeId = computed(() => nodeRef.value?.id as NodeId | undefined)
|
||||
|
||||
const hasExecutionOutput = computed(() => {
|
||||
const node = nodeRef.value
|
||||
if (!node) return false
|
||||
|
||||
const outputs = nodeOutputStore.nodeOutputs
|
||||
|
||||
const locatorId = nodeToNodeLocatorId(node)
|
||||
if (outputs[locatorId]?.images?.length) return true
|
||||
|
||||
const inner = innerGLSLNode
|
||||
if (inner) {
|
||||
const innerLocatorId = nodeToNodeLocatorId(inner)
|
||||
if (outputs[innerLocatorId]?.images?.length) return true
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
const shouldRender = computed(
|
||||
() =>
|
||||
(isGLSLNode.value || isGLSLSubgraphNode.value) && hasExecutionOutput.value
|
||||
)
|
||||
|
||||
watch(
|
||||
shouldRender,
|
||||
(v) => {
|
||||
isActiveOut.value = v
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const shaderSource = computed(() => {
|
||||
const gId = graphId.value
|
||||
if (!gId) return undefined
|
||||
|
||||
if (isGLSLNode.value) {
|
||||
const nId = nodeId.value
|
||||
if (nId == null) return undefined
|
||||
return widgetValueStore.getWidget(gId, nId, 'fragment_shader')?.value as
|
||||
| string
|
||||
| undefined
|
||||
}
|
||||
|
||||
const inner = innerGLSLNode
|
||||
if (inner) {
|
||||
return widgetValueStore.getWidget(
|
||||
gId,
|
||||
inner.id as NodeId,
|
||||
'fragment_shader'
|
||||
)?.value as string | undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
const rendererConfig = computed(() => {
|
||||
const inner = innerGLSLNode
|
||||
if (inner) return getAutogrowLimits(inner)
|
||||
|
||||
const node = nodeRef.value
|
||||
if (!node)
|
||||
return {
|
||||
maxInputs: 5,
|
||||
maxFloatUniforms: 20,
|
||||
maxIntUniforms: 20,
|
||||
maxBoolUniforms: 10,
|
||||
maxCurves: 4
|
||||
}
|
||||
return getAutogrowLimits(node)
|
||||
})
|
||||
|
||||
const uniformSources = computed(() => {
|
||||
const node = nodeRef.value
|
||||
const inner = innerGLSLNode
|
||||
if (!node?.isSubgraphNode() || !inner) return null
|
||||
return extractUniformSources(inner, node.subgraph as Subgraph)
|
||||
})
|
||||
|
||||
const { floatValues, intValues, boolValues, curveValues } = useGLSLUniforms(
|
||||
graphId,
|
||||
nodeId,
|
||||
nodeRef,
|
||||
uniformSources,
|
||||
rendererConfig
|
||||
)
|
||||
|
||||
function loadInputImages(): void {
|
||||
const node = nodeRef.value
|
||||
if (!node?.inputs || !renderer) return
|
||||
|
||||
if (isGLSLSubgraphNode.value) {
|
||||
let imageSlotIndex = 0
|
||||
for (let slot = 0; slot < node.inputs.length; slot++) {
|
||||
if (node.inputs[slot].type !== 'IMAGE') continue
|
||||
const upstreamNode = node.getInputNode(slot)
|
||||
if (upstreamNode?.imgs?.length) {
|
||||
renderer.bindInputImage(imageSlotIndex, upstreamNode.imgs[0])
|
||||
}
|
||||
imageSlotIndex++
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let imageSlotIndex = 0
|
||||
for (let slot = 0; slot < node.inputs.length; slot++) {
|
||||
const input = node.inputs[slot]
|
||||
if (!input.name.startsWith('images.image')) continue
|
||||
|
||||
const upstreamNode = node.getInputNode(slot)
|
||||
if (upstreamNode?.imgs?.length) {
|
||||
renderer.bindInputImage(imageSlotIndex, upstreamNode.imgs[0])
|
||||
imageSlotIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
const owner = ownerSubgraphNode
|
||||
if (owner) {
|
||||
const img = getImageThroughSubgraphBoundary(node, slot, owner)
|
||||
if (img) {
|
||||
renderer.bindInputImage(imageSlotIndex, img)
|
||||
}
|
||||
}
|
||||
imageSlotIndex++
|
||||
}
|
||||
}
|
||||
|
||||
function getResolution(): [number, number] {
|
||||
const node = nodeRef.value
|
||||
if (!node?.inputs) return [DEFAULT_SIZE, DEFAULT_SIZE]
|
||||
|
||||
if (isGLSLSubgraphNode.value) {
|
||||
for (let slot = 0; slot < node.inputs.length; slot++) {
|
||||
if (node.inputs[slot].type !== 'IMAGE') continue
|
||||
const upstreamNode = node.getInputNode(slot)
|
||||
if (!upstreamNode?.imgs?.length) continue
|
||||
const img = upstreamNode.imgs[0]
|
||||
return clampResolution(
|
||||
img.naturalWidth || DEFAULT_SIZE,
|
||||
img.naturalHeight || DEFAULT_SIZE
|
||||
)
|
||||
}
|
||||
return [DEFAULT_SIZE, DEFAULT_SIZE]
|
||||
}
|
||||
|
||||
for (let slot = 0; slot < node.inputs.length; slot++) {
|
||||
const input = node.inputs[slot]
|
||||
if (!input.name.startsWith('images.image')) continue
|
||||
|
||||
const upstreamNode = node.getInputNode(slot)
|
||||
if (upstreamNode?.imgs?.length) {
|
||||
const img = upstreamNode.imgs[0]
|
||||
return clampResolution(
|
||||
img.naturalWidth || DEFAULT_SIZE,
|
||||
img.naturalHeight || DEFAULT_SIZE
|
||||
)
|
||||
}
|
||||
|
||||
const owner = ownerSubgraphNode
|
||||
if (owner) {
|
||||
const img = getImageThroughSubgraphBoundary(node, slot, owner)
|
||||
if (img) {
|
||||
return clampResolution(
|
||||
img.naturalWidth || DEFAULT_SIZE,
|
||||
img.naturalHeight || DEFAULT_SIZE
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const gId = graphId.value
|
||||
const nId = nodeId.value
|
||||
if (gId && nId != null) {
|
||||
const widthWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
nId,
|
||||
'size_mode.width'
|
||||
)
|
||||
const heightWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
nId,
|
||||
'size_mode.height'
|
||||
)
|
||||
if (widthWidget && heightWidget) {
|
||||
return clampResolution(
|
||||
normalizeDimension(widthWidget.value),
|
||||
normalizeDimension(heightWidget.value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return [DEFAULT_SIZE, DEFAULT_SIZE]
|
||||
}
|
||||
|
||||
let disposed = false
|
||||
let lastRendererConfig: GLSLRendererConfig | null = null
|
||||
|
||||
function ensureRenderer(): ReturnType<typeof useGLSLRenderer> {
|
||||
const config = rendererConfig.value
|
||||
if (renderer && lastRendererConfig) {
|
||||
const changed =
|
||||
config.maxInputs !== lastRendererConfig.maxInputs ||
|
||||
config.maxFloatUniforms !== lastRendererConfig.maxFloatUniforms ||
|
||||
config.maxIntUniforms !== lastRendererConfig.maxIntUniforms ||
|
||||
config.maxBoolUniforms !== lastRendererConfig.maxBoolUniforms ||
|
||||
config.maxCurves !== lastRendererConfig.maxCurves
|
||||
if (changed) {
|
||||
renderer.dispose()
|
||||
renderer = null
|
||||
rendererReady = false
|
||||
}
|
||||
}
|
||||
if (!renderer) {
|
||||
renderer = useGLSLRenderer(config)
|
||||
lastRendererConfig = { ...config }
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
async function renderPreview(): Promise<void> {
|
||||
const requestId = ++renderRequestId
|
||||
const source = shaderSource.value
|
||||
if (!source || !shouldRender.value) return
|
||||
|
||||
const r = ensureRenderer()
|
||||
|
||||
try {
|
||||
if (!rendererReady) {
|
||||
const [w, h] = getResolution()
|
||||
if (!r.init(w, h)) {
|
||||
lastError.value = 'WebGL2 not available'
|
||||
return
|
||||
}
|
||||
rendererReady = true
|
||||
}
|
||||
|
||||
const result = r.compileFragment(source)
|
||||
if (!result.success) {
|
||||
lastError.value = result.log
|
||||
return
|
||||
}
|
||||
lastError.value = null
|
||||
|
||||
const [w, h] = getResolution()
|
||||
r.setResolution(w, h)
|
||||
|
||||
loadInputImages()
|
||||
|
||||
for (let i = 0; i < floatValues.value.length; i++) {
|
||||
r.setFloatUniform(i, floatValues.value[i])
|
||||
}
|
||||
for (let i = 0; i < intValues.value.length; i++) {
|
||||
r.setIntUniform(i, intValues.value[i])
|
||||
}
|
||||
for (let i = 0; i < boolValues.value.length; i++) {
|
||||
r.setBoolUniform(i, boolValues.value[i])
|
||||
}
|
||||
const curves = curveValues.value
|
||||
for (let i = 0; i < curves.length; i++) {
|
||||
r.bindCurveTexture(i, curveDataToFloatLUT(curves[i]))
|
||||
}
|
||||
|
||||
r.render()
|
||||
|
||||
const blob = await r.toBlob()
|
||||
if (requestId !== renderRequestId || disposed) return
|
||||
const blobUrl = createSharedObjectUrl(blob)
|
||||
try {
|
||||
const inner = innerGLSLNode
|
||||
if (inner) {
|
||||
const innerLocatorId = nodeToNodeLocatorId(inner)
|
||||
nodeOutputStore.setNodePreviewsByLocatorId(innerLocatorId, [blobUrl])
|
||||
} else {
|
||||
const nId = nodeId.value
|
||||
if (nId != null) {
|
||||
nodeOutputStore.setNodePreviewsByNodeId(nId, [blobUrl])
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
releaseSharedObjectUrl(blobUrl)
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== renderRequestId) return
|
||||
lastError.value =
|
||||
error instanceof Error ? error.message : 'Failed to render preview'
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedRender = debounce((): void => {
|
||||
void renderPreview()
|
||||
}, DEBOUNCE_MS)
|
||||
|
||||
watch(
|
||||
shouldRender,
|
||||
(active) => {
|
||||
if (isGLSLNode.value) {
|
||||
const node = nodeRef.value
|
||||
if (node) node.hideOutputImages = active
|
||||
}
|
||||
if (active) debouncedRender()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[
|
||||
floatValues.value,
|
||||
intValues.value,
|
||||
boolValues.value,
|
||||
curveValues.value
|
||||
] as const,
|
||||
() => {
|
||||
if (shouldRender.value) debouncedRender()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(shaderSource, () => {
|
||||
if (shouldRender.value) debouncedRender()
|
||||
})
|
||||
|
||||
// Return dispose function for the inner tier
|
||||
return () => {
|
||||
disposed = true
|
||||
debouncedRender.cancel()
|
||||
renderer?.dispose()
|
||||
renderer = null
|
||||
|
||||
// Revoke preview blob URLs to avoid memory leaks
|
||||
const inner = innerGLSLNode
|
||||
if (inner) {
|
||||
const locatorId = nodeToNodeLocatorId(inner)
|
||||
nodeOutputStore.revokePreviewsByLocatorId(locatorId)
|
||||
} else {
|
||||
const nId = nodeId.value
|
||||
if (nId != null) {
|
||||
const locatorId = nodeToNodeLocatorId(nodeRef.value!)
|
||||
nodeOutputStore.revokePreviewsByLocatorId(locatorId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/renderer/glsl/useGLSLRenderer.test.ts
Normal file
61
src/renderer/glsl/useGLSLRenderer.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
|
||||
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual('vue')
|
||||
return {
|
||||
...actual,
|
||||
onScopeDispose: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
describe('useGLSLRenderer', () => {
|
||||
it('returns renderer API with expected methods', async () => {
|
||||
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
|
||||
const renderer = useGLSLRenderer()
|
||||
|
||||
expect(renderer).toHaveProperty('init')
|
||||
expect(renderer).toHaveProperty('compileFragment')
|
||||
expect(renderer).toHaveProperty('setResolution')
|
||||
expect(renderer).toHaveProperty('setFloatUniform')
|
||||
expect(renderer).toHaveProperty('setIntUniform')
|
||||
expect(renderer).toHaveProperty('bindInputImage')
|
||||
expect(renderer).toHaveProperty('render')
|
||||
expect(renderer).toHaveProperty('readPixels')
|
||||
expect(renderer).toHaveProperty('toBlob')
|
||||
expect(renderer).toHaveProperty('dispose')
|
||||
})
|
||||
|
||||
it('init returns false when WebGL2 is unavailable', async () => {
|
||||
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
|
||||
const renderer = useGLSLRenderer()
|
||||
expect(renderer.init(256, 256)).toBe(false)
|
||||
})
|
||||
|
||||
it('compileFragment reports error before initialization', async () => {
|
||||
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
|
||||
const renderer = useGLSLRenderer()
|
||||
const result = renderer.compileFragment('void main() {}')
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('toBlob rejects before initialization', async () => {
|
||||
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
|
||||
const renderer = useGLSLRenderer()
|
||||
await expect(renderer.toBlob()).rejects.toThrow('Renderer not initialized')
|
||||
})
|
||||
|
||||
it('accepts custom config without error', async () => {
|
||||
const { useGLSLRenderer } = await import('@/renderer/glsl/useGLSLRenderer')
|
||||
const config: GLSLRendererConfig = {
|
||||
maxInputs: 3,
|
||||
maxFloatUniforms: 2,
|
||||
maxIntUniforms: 1,
|
||||
maxBoolUniforms: 1,
|
||||
maxCurves: 2
|
||||
}
|
||||
const renderer = useGLSLRenderer(config)
|
||||
expect(renderer.init(256, 256)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,3 @@
|
||||
import { onScopeDispose } from 'vue'
|
||||
|
||||
import { detectPassCount } from '@/renderer/glsl/glslUtils'
|
||||
|
||||
const VERTEX_SHADER_SOURCE = `#version 300 es
|
||||
@@ -17,12 +15,16 @@ export interface GLSLRendererConfig {
|
||||
maxInputs: number
|
||||
maxFloatUniforms: number
|
||||
maxIntUniforms: number
|
||||
maxBoolUniforms: number
|
||||
maxCurves: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: GLSLRendererConfig = {
|
||||
maxInputs: 5,
|
||||
maxFloatUniforms: 5,
|
||||
maxIntUniforms: 5
|
||||
maxFloatUniforms: 20,
|
||||
maxIntUniforms: 20,
|
||||
maxBoolUniforms: 10,
|
||||
maxCurves: 4
|
||||
}
|
||||
|
||||
interface CompileResult {
|
||||
@@ -50,15 +52,22 @@ function compileShader(
|
||||
}
|
||||
|
||||
export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
|
||||
const { maxInputs, maxFloatUniforms, maxIntUniforms } = config
|
||||
const {
|
||||
maxInputs,
|
||||
maxFloatUniforms,
|
||||
maxIntUniforms,
|
||||
maxBoolUniforms,
|
||||
maxCurves
|
||||
} = config
|
||||
|
||||
const uniformNames = [
|
||||
'u_resolution',
|
||||
'u_pass',
|
||||
'u_prevPass',
|
||||
...Array.from({ length: maxInputs }, (_, i) => `u_image${i}`),
|
||||
...Array.from({ length: maxFloatUniforms }, (_, i) => `u_float${i}`),
|
||||
...Array.from({ length: maxIntUniforms }, (_, i) => `u_int${i}`)
|
||||
...Array.from({ length: maxIntUniforms }, (_, i) => `u_int${i}`),
|
||||
...Array.from({ length: maxBoolUniforms }, (_, i) => `u_bool${i}`),
|
||||
...Array.from({ length: maxCurves }, (_, i) => `u_curve${i}`)
|
||||
]
|
||||
|
||||
let canvas: OffscreenCanvas | null = null
|
||||
@@ -72,9 +81,13 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
|
||||
const inputTextures: (WebGLTexture | null)[] = Array.from<null>({
|
||||
length: maxInputs
|
||||
}).fill(null)
|
||||
const curveTextures: (WebGLTexture | null)[] = Array.from<null>({
|
||||
length: maxCurves
|
||||
}).fill(null)
|
||||
const uniformLocations = new Map<string, WebGLUniformLocation | null>()
|
||||
let passCount = 1
|
||||
let disposed = false
|
||||
let lastCompiledSource: string | null = null
|
||||
|
||||
function initPingPongFBOs(
|
||||
ctx: WebGL2RenderingContext,
|
||||
@@ -92,12 +105,12 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
|
||||
ctx.texImage2D(
|
||||
ctx.TEXTURE_2D,
|
||||
0,
|
||||
ctx.RGBA8,
|
||||
ctx.RGBA16F,
|
||||
width,
|
||||
height,
|
||||
0,
|
||||
ctx.RGBA,
|
||||
ctx.UNSIGNED_BYTE,
|
||||
ctx.HALF_FLOAT,
|
||||
null
|
||||
)
|
||||
ctx.texParameteri(ctx.TEXTURE_2D, ctx.TEXTURE_MIN_FILTER, ctx.LINEAR)
|
||||
@@ -191,6 +204,9 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
|
||||
if (!ctx) return false
|
||||
|
||||
gl = ctx
|
||||
|
||||
if (!gl.getExtension('EXT_color_buffer_float')) return false
|
||||
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
|
||||
vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE)
|
||||
initPingPongFBOs(gl, width, height)
|
||||
@@ -206,6 +222,11 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
|
||||
|
||||
passCount = Math.min(detectPassCount(source), MAX_PASSES)
|
||||
|
||||
if (source === lastCompiledSource && program) {
|
||||
return { success: true, log: '' }
|
||||
}
|
||||
lastCompiledSource = source
|
||||
|
||||
if (fragmentShader) {
|
||||
gl.deleteShader(fragmentShader)
|
||||
fragmentShader = null
|
||||
@@ -270,6 +291,51 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
|
||||
}
|
||||
}
|
||||
|
||||
function setBoolUniform(index: number, value: boolean): void {
|
||||
if (disposed || !program || !gl) return
|
||||
const loc = uniformLocations.get(`u_bool${index}`)
|
||||
if (loc != null) {
|
||||
gl.useProgram(program)
|
||||
gl.uniform1i(loc, value ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
function bindCurveTexture(index: number, lut: Float32Array): void {
|
||||
if (disposed || !gl) return
|
||||
if (index < 0 || index >= maxCurves) return
|
||||
|
||||
if (curveTextures[index]) {
|
||||
gl.deleteTexture(curveTextures[index])
|
||||
curveTextures[index] = null
|
||||
}
|
||||
|
||||
const texture = gl.createTexture()
|
||||
if (!texture) return
|
||||
|
||||
const unit = maxInputs + index
|
||||
gl.activeTexture(gl.TEXTURE0 + unit)
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture)
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.R16F,
|
||||
lut.length,
|
||||
1,
|
||||
0,
|
||||
gl.RED,
|
||||
gl.FLOAT,
|
||||
lut
|
||||
)
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
|
||||
curveTextures[index] = texture
|
||||
}
|
||||
|
||||
function bindInputImage(
|
||||
index: number,
|
||||
image: HTMLImageElement | ImageBitmap
|
||||
@@ -304,6 +370,7 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
|
||||
if (disposed || !program || !pingPongFBOs || !gl || !canvas) return
|
||||
|
||||
gl.useProgram(program)
|
||||
gl.disable(gl.BLEND)
|
||||
|
||||
const resLoc = uniformLocations.get('u_resolution')
|
||||
if (resLoc != null) {
|
||||
@@ -319,8 +386,15 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
|
||||
}
|
||||
}
|
||||
|
||||
const prevPassUnit = maxInputs
|
||||
const prevPassLoc = uniformLocations.get('u_prevPass')
|
||||
for (let i = 0; i < maxCurves; i++) {
|
||||
const loc = uniformLocations.get(`u_curve${i}`)
|
||||
if (loc != null && curveTextures[i]) {
|
||||
const unit = maxInputs + i
|
||||
gl.activeTexture(gl.TEXTURE0 + unit)
|
||||
gl.bindTexture(gl.TEXTURE_2D, curveTextures[i])
|
||||
gl.uniform1i(loc, unit)
|
||||
}
|
||||
}
|
||||
|
||||
for (let pass = 0; pass < passCount; pass++) {
|
||||
const passLoc = uniformLocations.get('u_pass')
|
||||
@@ -328,31 +402,26 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
|
||||
|
||||
const isLastPass = pass === passCount - 1
|
||||
const writeIdx = pass % 2
|
||||
const readIdx = 1 - writeIdx
|
||||
|
||||
if (isLastPass) {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
|
||||
} else {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, pingPongFBOs[writeIdx])
|
||||
}
|
||||
|
||||
// Note: u_prevPass uses ping-pong FBOs rather than overwriting the input
|
||||
// texture in-place as the backend does for single-input iteration.
|
||||
if (pass > 0 && prevPassLoc != null) {
|
||||
gl.activeTexture(gl.TEXTURE0 + prevPassUnit)
|
||||
gl.bindTexture(gl.TEXTURE_2D, pingPongTextures![readIdx])
|
||||
gl.uniform1i(prevPassLoc, prevPassUnit)
|
||||
}
|
||||
|
||||
// Ping-pong FBOs have a single color attachment, so intermediate
|
||||
// passes always target COLOR_ATTACHMENT0. MRT is only possible on
|
||||
// the default framebuffer (last pass).
|
||||
if (isLastPass) {
|
||||
gl.drawBuffers([gl.BACK])
|
||||
} else {
|
||||
gl.bindFramebuffer(gl.FRAMEBUFFER, pingPongFBOs[writeIdx])
|
||||
gl.drawBuffers([gl.COLOR_ATTACHMENT0])
|
||||
}
|
||||
|
||||
// Match backend behavior: pass > 0 binds previous pass output to
|
||||
// texture unit 0, overriding u_image0 so shaders read the previous
|
||||
// pass result via the same sampler.
|
||||
if (pass > 0) {
|
||||
const sourceTexture = pingPongTextures![(pass - 1) % 2]
|
||||
gl.activeTexture(gl.TEXTURE0)
|
||||
gl.bindTexture(gl.TEXTURE_2D, sourceTexture)
|
||||
}
|
||||
|
||||
gl.clearColor(0, 0, 0, 0)
|
||||
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3)
|
||||
}
|
||||
}
|
||||
@@ -371,7 +440,7 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
|
||||
|
||||
async function toBlob(): Promise<Blob> {
|
||||
if (!canvas) throw new Error('Renderer not initialized')
|
||||
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
|
||||
return canvas.convertToBlob({ type: 'image/webp', quality: 0.92 })
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
@@ -384,6 +453,11 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
|
||||
}
|
||||
inputTextures.fill(null)
|
||||
|
||||
for (const tex of curveTextures) {
|
||||
if (tex) gl.deleteTexture(tex)
|
||||
}
|
||||
curveTextures.fill(null)
|
||||
|
||||
if (fallbackTexture) {
|
||||
gl.deleteTexture(fallbackTexture)
|
||||
fallbackTexture = null
|
||||
@@ -411,14 +485,14 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
|
||||
ext?.loseContext()
|
||||
}
|
||||
|
||||
onScopeDispose(dispose)
|
||||
|
||||
return {
|
||||
init,
|
||||
compileFragment,
|
||||
setResolution,
|
||||
setFloatUniform,
|
||||
setIntUniform,
|
||||
setBoolUniform,
|
||||
bindCurveTexture,
|
||||
bindInputImage,
|
||||
render,
|
||||
readPixels,
|
||||
|
||||
247
src/renderer/glsl/useGLSLUniforms.ts
Normal file
247
src/renderer/glsl/useGLSLUniforms.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ComputedRef } from 'vue'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { isCurveData } from '@/components/curve/curveUtils'
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
|
||||
|
||||
interface AutogrowGroup {
|
||||
max: number
|
||||
min: number
|
||||
prefix?: string
|
||||
}
|
||||
|
||||
export interface UniformSource {
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
}
|
||||
|
||||
export interface UniformSources {
|
||||
floats: UniformSource[]
|
||||
ints: UniformSource[]
|
||||
bools: UniformSource[]
|
||||
curves: UniformSource[]
|
||||
}
|
||||
|
||||
export function getAutogrowLimits(node: LGraphNode): GLSLRendererConfig {
|
||||
const defaults: GLSLRendererConfig = {
|
||||
maxInputs: 5,
|
||||
maxFloatUniforms: 20,
|
||||
maxIntUniforms: 20,
|
||||
maxBoolUniforms: 10,
|
||||
maxCurves: 4
|
||||
}
|
||||
|
||||
if (!('comfyDynamic' in node)) return defaults
|
||||
|
||||
const dynamic = node.comfyDynamic
|
||||
if (
|
||||
typeof dynamic !== 'object' ||
|
||||
dynamic === null ||
|
||||
!('autogrow' in dynamic)
|
||||
)
|
||||
return defaults
|
||||
|
||||
const groups = dynamic.autogrow as Record<string, AutogrowGroup> | undefined
|
||||
if (!groups) return defaults
|
||||
|
||||
return {
|
||||
maxInputs: groups['images']?.max ?? defaults.maxInputs,
|
||||
maxFloatUniforms: groups['floats']?.max ?? defaults.maxFloatUniforms,
|
||||
maxIntUniforms: groups['ints']?.max ?? defaults.maxIntUniforms,
|
||||
maxBoolUniforms: groups['bools']?.max ?? defaults.maxBoolUniforms,
|
||||
maxCurves: groups['curves']?.max ?? defaults.maxCurves
|
||||
}
|
||||
}
|
||||
|
||||
export function extractUniformSources(
|
||||
glslNode: LGraphNode,
|
||||
subgraph: Subgraph
|
||||
): UniformSources {
|
||||
const floats: UniformSource[] = []
|
||||
const ints: UniformSource[] = []
|
||||
const bools: UniformSource[] = []
|
||||
const curves: UniformSource[] = []
|
||||
|
||||
if (!glslNode.inputs) return { floats, ints, bools, curves }
|
||||
|
||||
for (const input of glslNode.inputs) {
|
||||
if (input.link == null) continue
|
||||
|
||||
const link = subgraph.getLink(input.link)
|
||||
if (!link || link.origin_id === SUBGRAPH_INPUT_ID) continue
|
||||
|
||||
const sourceNode = subgraph.getNodeById(link.origin_id)
|
||||
if (!sourceNode?.widgets?.[0]) continue
|
||||
|
||||
const inputName = input.name ?? ''
|
||||
const dotIndex = inputName.indexOf('.')
|
||||
if (dotIndex === -1) continue
|
||||
|
||||
const prefix = inputName.slice(0, dotIndex)
|
||||
const source: UniformSource = {
|
||||
nodeId: sourceNode.id as NodeId,
|
||||
widgetName: sourceNode.widgets[0].name
|
||||
}
|
||||
|
||||
if (prefix === 'floats') floats.push(source)
|
||||
else if (prefix === 'ints') ints.push(source)
|
||||
else if (prefix === 'bools') bools.push(source)
|
||||
else if (prefix === 'curves') curves.push(source)
|
||||
}
|
||||
|
||||
return { floats, ints, bools, curves }
|
||||
}
|
||||
|
||||
export function useGLSLUniforms(
|
||||
graphId: ComputedRef<UUID | undefined>,
|
||||
nodeId: ComputedRef<NodeId | undefined>,
|
||||
nodeRef: ComputedRef<LGraphNode | null>,
|
||||
uniformSources: ComputedRef<UniformSources | null>,
|
||||
rendererConfig: ComputedRef<GLSLRendererConfig>
|
||||
) {
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
function collectValues<T>(
|
||||
subgraphSources: UniformSource[] | undefined,
|
||||
groupName: string,
|
||||
uniformPrefix: string,
|
||||
maxCount: number,
|
||||
coerce: (value: unknown) => T,
|
||||
defaultValue: T
|
||||
): T[] {
|
||||
const gId = graphId.value
|
||||
if (!gId) return []
|
||||
|
||||
if (subgraphSources) {
|
||||
return subgraphSources.map(({ nodeId: nId, widgetName }) => {
|
||||
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
|
||||
return coerce(widget?.value ?? defaultValue)
|
||||
})
|
||||
}
|
||||
|
||||
const nId = nodeId.value
|
||||
const node = nodeRef.value
|
||||
if (nId == null || !node) return []
|
||||
|
||||
const values: T[] = []
|
||||
for (let i = 0; i < maxCount; i++) {
|
||||
const inputName = `${groupName}.${uniformPrefix}${i}`
|
||||
const widget = widgetValueStore.getWidget(gId, nId, inputName)
|
||||
if (widget !== undefined) {
|
||||
values.push(coerce(widget.value))
|
||||
continue
|
||||
}
|
||||
|
||||
const slot = node.inputs?.findIndex((inp) => inp.name === inputName)
|
||||
if (slot == null || slot < 0) break
|
||||
|
||||
const upstreamNode = node.getInputNode(slot)
|
||||
if (!upstreamNode) break
|
||||
const upstreamWidgets = widgetValueStore.getNodeWidgets(
|
||||
gId,
|
||||
upstreamNode.id as NodeId
|
||||
)
|
||||
if (upstreamWidgets.length === 0) break
|
||||
values.push(coerce(upstreamWidgets[0].value))
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
const toNumber = (v: unknown): number => Number(v) || 0
|
||||
const toBool = (v: unknown): boolean => Boolean(v)
|
||||
|
||||
const floatValues = computed(() =>
|
||||
collectValues(
|
||||
uniformSources.value?.floats,
|
||||
'floats',
|
||||
'u_float',
|
||||
rendererConfig.value.maxFloatUniforms,
|
||||
toNumber,
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
const intValues = computed(() =>
|
||||
collectValues(
|
||||
uniformSources.value?.ints,
|
||||
'ints',
|
||||
'u_int',
|
||||
rendererConfig.value.maxIntUniforms,
|
||||
toNumber,
|
||||
0
|
||||
)
|
||||
)
|
||||
|
||||
const boolValues = computed(() =>
|
||||
collectValues(
|
||||
uniformSources.value?.bools,
|
||||
'bools',
|
||||
'u_bool',
|
||||
rendererConfig.value.maxBoolUniforms,
|
||||
toBool,
|
||||
false
|
||||
)
|
||||
)
|
||||
|
||||
const curveValues = computed((): CurveData[] => {
|
||||
const gId = graphId.value
|
||||
if (!gId) return []
|
||||
|
||||
const sources = uniformSources.value?.curves
|
||||
if (sources && sources.length > 0) {
|
||||
return sources
|
||||
.map(({ nodeId: nId, widgetName }) => {
|
||||
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
|
||||
return widget && isCurveData(widget.value)
|
||||
? (widget.value as CurveData)
|
||||
: null
|
||||
})
|
||||
.filter((v): v is CurveData => v !== null)
|
||||
}
|
||||
|
||||
const node = nodeRef.value
|
||||
const nId = nodeId.value
|
||||
if (nId == null || !node?.inputs) return []
|
||||
|
||||
const values: CurveData[] = []
|
||||
const max = rendererConfig.value.maxCurves
|
||||
for (let i = 0; i < max; i++) {
|
||||
const inputName = `curves.u_curve${i}`
|
||||
|
||||
const widget = widgetValueStore.getWidget(gId, nId, inputName)
|
||||
if (widget && isCurveData(widget.value)) {
|
||||
values.push(widget.value as CurveData)
|
||||
continue
|
||||
}
|
||||
|
||||
const slot = node.inputs.findIndex((inp) => inp.name === inputName)
|
||||
if (slot < 0) break
|
||||
|
||||
const upstreamNode = node.getInputNode(slot)
|
||||
if (!upstreamNode) break
|
||||
|
||||
const upstreamWidgets = widgetValueStore.getNodeWidgets(
|
||||
gId,
|
||||
upstreamNode.id as NodeId
|
||||
)
|
||||
const curveWidget = upstreamWidgets.find((w) => isCurveData(w.value))
|
||||
if (!curveWidget) break
|
||||
values.push(curveWidget.value as CurveData)
|
||||
}
|
||||
return values
|
||||
})
|
||||
|
||||
return {
|
||||
floatValues,
|
||||
intValues,
|
||||
boolValues,
|
||||
curveValues
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,17 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
) {
|
||||
const nodeLocatorId = executionIdToNodeLocatorId(app.rootGraph, executionId)
|
||||
if (!nodeLocatorId) return
|
||||
setNodePreviewsByLocatorId(nodeLocatorId, previewImages)
|
||||
latestPreview.value = previewImages
|
||||
}
|
||||
|
||||
/**
|
||||
* Set node preview images by NodeLocatorId directly.
|
||||
*/
|
||||
function setNodePreviewsByLocatorId(
|
||||
nodeLocatorId: NodeLocatorId,
|
||||
previewImages: string[]
|
||||
) {
|
||||
const existingPreviews = app.nodePreviewImages[nodeLocatorId]
|
||||
if (scheduledRevoke[nodeLocatorId]) {
|
||||
scheduledRevoke[nodeLocatorId].stop()
|
||||
@@ -274,7 +285,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
for (const url of previewImages) {
|
||||
retainSharedObjectUrl(url)
|
||||
}
|
||||
latestPreview.value = previewImages
|
||||
app.nodePreviewImages[nodeLocatorId] = previewImages
|
||||
nodePreviewImages.value[nodeLocatorId] = previewImages
|
||||
}
|
||||
@@ -290,22 +300,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
nodeId: string | number,
|
||||
previewImages: string[]
|
||||
) {
|
||||
const nodeLocatorId = nodeIdToNodeLocatorId(nodeId)
|
||||
const existingPreviews = app.nodePreviewImages[nodeLocatorId]
|
||||
if (scheduledRevoke[nodeLocatorId]) {
|
||||
scheduledRevoke[nodeLocatorId].stop()
|
||||
delete scheduledRevoke[nodeLocatorId]
|
||||
}
|
||||
if (existingPreviews?.[Symbol.iterator]) {
|
||||
for (const url of existingPreviews) {
|
||||
releaseSharedObjectUrl(url)
|
||||
}
|
||||
}
|
||||
for (const url of previewImages) {
|
||||
retainSharedObjectUrl(url)
|
||||
}
|
||||
app.nodePreviewImages[nodeLocatorId] = previewImages
|
||||
nodePreviewImages.value[nodeLocatorId] = previewImages
|
||||
setNodePreviewsByLocatorId(nodeIdToNodeLocatorId(nodeId), previewImages)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -486,6 +481,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
setNodeOutputs,
|
||||
setNodeOutputsByExecutionId,
|
||||
setNodePreviewsByExecutionId,
|
||||
setNodePreviewsByLocatorId,
|
||||
setNodePreviewsByNodeId,
|
||||
updateNodeImages,
|
||||
refreshNodeOutputs,
|
||||
@@ -493,6 +489,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
|
||||
// Cleanup
|
||||
revokePreviewsByExecutionId,
|
||||
revokePreviewsByLocatorId,
|
||||
revokeAllPreviews,
|
||||
revokeSubgraphPreviews,
|
||||
removeNodeOutputs,
|
||||
|
||||
Reference in New Issue
Block a user