Compare commits

..

1 Commits

Author SHA1 Message Date
bymyself
7b6f431496 refactor: extract auth-routing from workspaceApi to auth domain
- Add getAuthHeaderOrThrow() and getFirebaseAuthHeaderOrThrow() to authStore
- Delegate workspaceApi's auth-or-throw helpers to authStore methods
- Remove unused i18n import from workspaceApi
- Update shared mock factory with new methods
- Thrown errors now use AuthStoreError (auth domain)
2026-03-29 17:26:12 -07:00
45 changed files with 271 additions and 2446 deletions

View File

@@ -1,47 +0,0 @@
<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>

View File

@@ -1,66 +0,0 @@
<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>

View File

@@ -1,77 +0,0 @@
<!-- 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>

View File

@@ -1,62 +0,0 @@
<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>

View File

@@ -1,68 +0,0 @@
<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>

View File

@@ -1,26 +0,0 @@
<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">
&laquo;
</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>

View File

@@ -1,51 +0,0 @@
<!-- 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>

View File

@@ -1,58 +0,0 @@
<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>

View File

@@ -1,92 +0,0 @@
<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">
&ldquo;{{ testimonial.quote }}&rdquo;
</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>

View File

@@ -1,74 +0,0 @@
<!-- 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>

View File

@@ -1,67 +0,0 @@
<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>

View File

@@ -1,34 +0,0 @@
---
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>

View File

@@ -1,34 +0,0 @@
---
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>

View File

@@ -1,50 +0,0 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "ImageCropV2",
"pos": [50, 50],
"size": [400, 500],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [
{
"x": 0,
"y": 0,
"width": 512,
"height": 512
}
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -1,100 +0,0 @@
{
"last_node_id": 3,
"last_link_id": 2,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [50, 50],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [1]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 2,
"type": "ImageCropV2",
"pos": [450, 50],
"size": [400, 500],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": 1
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2]
}
],
"properties": {
"Node name for S&R": "ImageCropV2"
},
"widgets_values": [
{
"x": 10,
"y": 10,
"width": 100,
"height": 100
}
]
},
{
"id": 3,
"type": "PreviewImage",
"pos": [900, 50],
"size": [315, 270],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"links": [
[1, 1, 0, 2, 0, "IMAGE"],
[2, 2, 0, 3, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -1,140 +0,0 @@
import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
type CropValue = { x: number; y: number; width: number; height: number } | null
async function getCropValue(comfyPage: ComfyPage): Promise<CropValue> {
return comfyPage.page.evaluate(() => {
const n = window.app!.graph.getNodeById(2)
const w = n?.widgets?.find((w) => w.type === 'imagecrop')
const v = w?.value as Record<string, number> | undefined
return v ? { x: v.x, y: v.y, width: v.width, height: v.height } : null
})
}
test.describe('Image Crop', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
})
test.describe('without source image', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
await comfyPage.vueNodes.waitForNodes()
})
test(
'Shows empty state when no input image is connected',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect
.soft(node.locator('.icon-\\[lucide--image\\]'))
.toBeVisible()
await expect.soft(node).toContainText('No input image connected')
await expect.soft(node.locator('.cursor-move')).toHaveCount(0)
await expect.soft(node.locator('img')).toHaveCount(0)
}
)
test(
'Renders controls in default state',
{ tag: '@smoke' },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('1')
await expect(node).toBeVisible()
await expect(node.getByText('Ratio')).toBeVisible()
await expect(
node.locator('button:has(.icon-\\[lucide--lock-open\\])')
).toBeVisible()
await expect(node.locator('input')).toHaveCount(4)
}
)
})
test.describe('with source image after execution', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('widgets/image_crop_with_source')
await comfyPage.vueNodes.waitForNodes()
await comfyPage.runButton.click()
await expect(
comfyPage.vueNodes.getNodeLocator('2').locator('img')
).toBeVisible({ timeout: 30_000 })
})
test(
'Displays source image with crop overlay after execution',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
await expect(node.locator('.cursor-move')).toBeVisible()
await expect(node).toHaveScreenshot('image-crop-with-source.png')
}
)
test(
'Drag crop box updates crop position',
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage }) => {
const node = comfyPage.vueNodes.getNodeLocator('2')
const cropBox = node.locator('.cursor-move')
const box = await cropBox.boundingBox()
expect(box, 'Crop box not found').not.toBeNull()
const valueBefore = await getCropValue(comfyPage)
expect(
valueBefore,
'Widget value missing — check fixture setup'
).not.toBeNull()
const startX = box!.x + box!.width / 2
const startY = box!.y + box!.height / 2
await cropBox.dispatchEvent('pointerdown', {
bubbles: true,
pointerId: 1,
clientX: startX,
clientY: startY
})
await cropBox.dispatchEvent('pointermove', {
bubbles: true,
pointerId: 1,
clientX: startX + 15,
clientY: startY + 10
})
await cropBox.dispatchEvent('pointermove', {
bubbles: true,
pointerId: 1,
clientX: startX + 30,
clientY: startY + 20
})
await cropBox.dispatchEvent('pointerup', {
bubbles: true,
pointerId: 1,
clientX: startX + 30,
clientY: startY + 20
})
await expect
.poll(() => getCropValue(comfyPage).then((v) => v?.x))
.toBeGreaterThan(valueBefore!.x)
const valueAfter = await getCropValue(comfyPage)
expect(valueAfter!.y).toBeGreaterThan(valueBefore!.y)
expect(valueAfter!.width).toBe(valueBefore!.width)
expect(valueAfter!.height).toBe(valueBefore!.height)
await expect(node).toHaveScreenshot('image-crop-after-drag.png')
}
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

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

View File

@@ -192,15 +192,3 @@ 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
}

View File

@@ -15680,10 +15680,6 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "صورة UV",
"tooltip": null
}
}
},

View File

@@ -15680,10 +15680,6 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "uv_image",
"tooltip": null
}
}
},

View File

@@ -15680,10 +15680,6 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "imagen UV",
"tooltip": null
}
}
},

View File

@@ -15680,10 +15680,6 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "تصویر UV",
"tooltip": null
}
}
},

View File

@@ -15680,10 +15680,6 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "uv_image",
"tooltip": null
}
}
},

View File

@@ -15680,10 +15680,6 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "uv画像",
"tooltip": null
}
}
},

View File

@@ -15680,10 +15680,6 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "uv_image",
"tooltip": null
}
}
},

View File

@@ -15680,10 +15680,6 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "imagem UV",
"tooltip": null
}
}
},

View File

@@ -15680,10 +15680,6 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "uv_image",
"tooltip": null
}
}
},

View File

@@ -15680,10 +15680,6 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "uv_görüntüsü",
"tooltip": null
}
}
},

View File

@@ -15680,10 +15680,6 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "uv_image",
"tooltip": null
}
}
},

View File

@@ -15680,10 +15680,6 @@
"1": {
"name": "FBX",
"tooltip": null
},
"2": {
"name": "uv_image",
"tooltip": null
}
}
},

View File

@@ -343,6 +343,10 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
}
}
function getWorkspaceToken(): string | undefined {
return workspaceToken.value ?? undefined
}
function clearWorkspaceContext(): void {
// Increment request ID to invalidate any in-flight stale refresh operations
refreshRequestId++
@@ -370,6 +374,7 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
switchWorkspace,
refreshToken,
getWorkspaceAuthHeader,
getWorkspaceToken,
clearWorkspaceContext
}
})

View File

@@ -284,7 +284,6 @@ 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'
@@ -731,8 +730,6 @@ 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

View File

@@ -1,40 +0,0 @@
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]
}

View File

@@ -1,331 +0,0 @@
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()
})
})
})

View File

@@ -1,500 +0,0 @@
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)
}
}
}
}

View File

@@ -1,61 +0,0 @@
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)
})
})

View File

@@ -1,3 +1,5 @@
import { onScopeDispose } from 'vue'
import { detectPassCount } from '@/renderer/glsl/glslUtils'
const VERTEX_SHADER_SOURCE = `#version 300 es
@@ -15,16 +17,12 @@ export interface GLSLRendererConfig {
maxInputs: number
maxFloatUniforms: number
maxIntUniforms: number
maxBoolUniforms: number
maxCurves: number
}
const DEFAULT_CONFIG: GLSLRendererConfig = {
maxInputs: 5,
maxFloatUniforms: 20,
maxIntUniforms: 20,
maxBoolUniforms: 10,
maxCurves: 4
maxFloatUniforms: 5,
maxIntUniforms: 5
}
interface CompileResult {
@@ -52,22 +50,15 @@ function compileShader(
}
export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
const {
maxInputs,
maxFloatUniforms,
maxIntUniforms,
maxBoolUniforms,
maxCurves
} = config
const { maxInputs, maxFloatUniforms, maxIntUniforms } = 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: maxBoolUniforms }, (_, i) => `u_bool${i}`),
...Array.from({ length: maxCurves }, (_, i) => `u_curve${i}`)
...Array.from({ length: maxIntUniforms }, (_, i) => `u_int${i}`)
]
let canvas: OffscreenCanvas | null = null
@@ -81,13 +72,9 @@ 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,
@@ -105,12 +92,12 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
ctx.texImage2D(
ctx.TEXTURE_2D,
0,
ctx.RGBA16F,
ctx.RGBA8,
width,
height,
0,
ctx.RGBA,
ctx.HALF_FLOAT,
ctx.UNSIGNED_BYTE,
null
)
ctx.texParameteri(ctx.TEXTURE_2D, ctx.TEXTURE_MIN_FILTER, ctx.LINEAR)
@@ -204,9 +191,6 @@ 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)
@@ -222,11 +206,6 @@ 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
@@ -291,51 +270,6 @@ 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
@@ -370,7 +304,6 @@ 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) {
@@ -386,15 +319,8 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
}
}
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)
}
}
const prevPassUnit = maxInputs
const prevPassLoc = uniformLocations.get('u_prevPass')
for (let pass = 0; pass < passCount; pass++) {
const passLoc = uniformLocations.get('u_pass')
@@ -402,26 +328,31 @@ 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)
gl.drawBuffers([gl.BACK])
} 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.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)
}
}
@@ -440,7 +371,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/webp', quality: 0.92 })
return canvas.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
}
function dispose(): void {
@@ -453,11 +384,6 @@ 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
@@ -485,14 +411,14 @@ export function useGLSLRenderer(config: GLSLRendererConfig = DEFAULT_CONFIG) {
ext?.loseContext()
}
onScopeDispose(dispose)
return {
init,
compileFragment,
setResolution,
setFloatUniform,
setIntUniform,
setBoolUniform,
bindCurveTexture,
bindInputImage,
render,
readPixels,

View File

@@ -1,247 +0,0 @@
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
}
}

View File

@@ -0,0 +1,211 @@
import type { User } from 'firebase/auth'
import * as firebaseAuth from 'firebase/auth'
import { setActivePinia } from 'pinia'
import type { Mock } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as vuefire from 'vuefire'
import { useAuthStore } from '@/stores/authStore'
import { createTestingPinia } from '@pinia/testing'
const { mockFeatureFlags } = vi.hoisted(() => ({
mockFeatureFlags: {
teamWorkspacesEnabled: false
}
}))
const { mockDistributionTypes } = vi.hoisted(() => ({
mockDistributionTypes: {
isCloud: true,
isDesktop: true
}
}))
const mockWorkspaceAuthHeader = vi.fn().mockReturnValue(null)
const mockGetWorkspaceToken = vi.fn().mockReturnValue(undefined)
const mockClearWorkspaceContext = vi.fn()
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({
getWorkspaceAuthHeader: mockWorkspaceAuthHeader,
getWorkspaceToken: mockGetWorkspaceToken,
clearWorkspaceContext: mockClearWorkspaceContext
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: mockFeatureFlags
})
}))
vi.mock('vuefire', () => ({
useFirebaseAuth: vi.fn()
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
createI18n: () => ({ global: { t: (key: string) => key } })
}))
vi.mock('firebase/auth', async (importOriginal) => {
const actual = await importOriginal<typeof firebaseAuth>()
return {
...actual,
signInWithEmailAndPassword: vi.fn(),
createUserWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
onAuthStateChanged: vi.fn(),
onIdTokenChanged: vi.fn(),
signInWithPopup: vi.fn(),
GoogleAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
},
GithubAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
},
getAdditionalUserInfo: vi.fn(),
setPersistence: vi.fn().mockResolvedValue(undefined)
}
})
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackAuth: vi.fn() })
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: () => ({ add: vi.fn() })
}))
vi.mock('@/services/dialogService')
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => ({
getAuthHeader: mockApiKeyGetAuthHeader,
getApiKey: vi.fn(),
currentUser: null,
isAuthenticated: false,
storeApiKey: vi.fn(),
clearStoredApiKey: vi.fn()
})
}))
type MockUser = Omit<User, 'getIdToken'> & { getIdToken: Mock }
describe('auth token priority chain', () => {
let store: ReturnType<typeof useAuthStore>
let authStateCallback: (user: User | null) => void
const mockAuth: Record<string, unknown> = {}
const mockUser: MockUser = {
uid: 'test-user-id',
email: 'test@example.com',
getIdToken: vi.fn().mockResolvedValue('firebase-token')
} as Partial<User> as MockUser
beforeEach(() => {
vi.resetAllMocks()
mockFeatureFlags.teamWorkspacesEnabled = false
mockWorkspaceAuthHeader.mockReturnValue(null)
mockGetWorkspaceToken.mockReturnValue(undefined)
mockApiKeyGetAuthHeader.mockReturnValue(null)
mockUser.getIdToken.mockResolvedValue('firebase-token')
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(
mockAuth as unknown as ReturnType<typeof vuefire.useFirebaseAuth>
)
vi.mocked(firebaseAuth.onAuthStateChanged).mockImplementation(
(_, callback) => {
authStateCallback = callback as (user: User | null) => void
;(callback as (user: User | null) => void)(mockUser)
return vi.fn()
}
)
setActivePinia(createTestingPinia({ stubActions: false }))
store = useAuthStore()
})
describe('getAuthHeader priority', () => {
it('returns workspace auth header when workspace is active and feature enabled', async () => {
mockFeatureFlags.teamWorkspacesEnabled = true
mockWorkspaceAuthHeader.mockReturnValue({
Authorization: 'Bearer workspace-token'
})
const header = await store.getAuthHeader()
expect(header).toEqual({
Authorization: 'Bearer workspace-token'
})
})
it('returns Firebase token when workspace is not active but user is authenticated', async () => {
mockFeatureFlags.teamWorkspacesEnabled = true
mockWorkspaceAuthHeader.mockReturnValue(null)
const header = await store.getAuthHeader()
expect(header).toEqual({
Authorization: 'Bearer firebase-token'
})
})
it('returns API key when neither workspace nor Firebase are available', async () => {
authStateCallback(null)
mockApiKeyGetAuthHeader.mockReturnValue({ 'X-API-KEY': 'test-key' })
const header = await store.getAuthHeader()
expect(header).toEqual({ 'X-API-KEY': 'test-key' })
})
it('returns null when no auth method is available', async () => {
authStateCallback(null)
const header = await store.getAuthHeader()
expect(header).toBeNull()
})
it('skips workspace header when team_workspaces feature is disabled', async () => {
mockFeatureFlags.teamWorkspacesEnabled = false
mockWorkspaceAuthHeader.mockReturnValue({
Authorization: 'Bearer workspace-token'
})
const header = await store.getAuthHeader()
expect(header).toEqual({
Authorization: 'Bearer firebase-token'
})
})
})
describe('getAuthToken priority', () => {
it('returns workspace token when workspace is active and feature enabled', async () => {
mockFeatureFlags.teamWorkspacesEnabled = true
mockGetWorkspaceToken.mockReturnValue('workspace-raw-token')
const token = await store.getAuthToken()
expect(token).toBe('workspace-raw-token')
})
it('returns Firebase token when workspace token is not available', async () => {
mockFeatureFlags.teamWorkspacesEnabled = true
mockGetWorkspaceToken.mockReturnValue(undefined)
const token = await store.getAuthToken()
expect(token).toBe('firebase-token')
})
})
})

View File

@@ -22,10 +22,10 @@ import { useFirebaseAuth } from 'vuefire'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import type { AuthHeader } from '@/types/authTypes'
import type { operations } from '@/types/comfyRegistryTypes'
@@ -110,15 +110,7 @@ export const useAuthStore = defineStore('auth', () => {
isInitialized.value = true
if (user === null) {
lastTokenUserId.value = null
// Clear workspace sessionStorage on logout to prevent stale tokens
try {
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN)
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
} catch {
// Ignore sessionStorage errors (e.g., in private browsing mode)
}
useWorkspaceAuthStore().clearWorkspaceContext()
}
// Reset balance when auth state changes
@@ -175,21 +167,8 @@ export const useAuthStore = defineStore('auth', () => {
*/
const getAuthHeader = async (): Promise<AuthHeader | null> => {
if (flags.teamWorkspacesEnabled) {
const workspaceToken = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.TOKEN
)
const expiresAt = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
)
if (workspaceToken && expiresAt) {
const expiryTime = parseInt(expiresAt, 10)
if (Date.now() < expiryTime) {
return {
Authorization: `Bearer ${workspaceToken}`
}
}
}
const wsHeader = useWorkspaceAuthStore().getWorkspaceAuthHeader()
if (wsHeader) return wsHeader
}
const token = await getIdToken()
@@ -218,19 +197,8 @@ export const useAuthStore = defineStore('auth', () => {
*/
const getAuthToken = async (): Promise<string | undefined> => {
if (flags.teamWorkspacesEnabled) {
const workspaceToken = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.TOKEN
)
const expiresAt = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
)
if (workspaceToken && expiresAt) {
const expiryTime = parseInt(expiresAt, 10)
if (Date.now() < expiryTime) {
return workspaceToken
}
}
const wsToken = useWorkspaceAuthStore().getWorkspaceToken()
if (wsToken) return wsToken
}
return await getIdToken()

View File

@@ -261,17 +261,6 @@ 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()
@@ -285,6 +274,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
for (const url of previewImages) {
retainSharedObjectUrl(url)
}
latestPreview.value = previewImages
app.nodePreviewImages[nodeLocatorId] = previewImages
nodePreviewImages.value[nodeLocatorId] = previewImages
}
@@ -300,7 +290,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
nodeId: string | number,
previewImages: string[]
) {
setNodePreviewsByLocatorId(nodeIdToNodeLocatorId(nodeId), previewImages)
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
}
/**
@@ -481,7 +486,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
setNodeOutputs,
setNodeOutputsByExecutionId,
setNodePreviewsByExecutionId,
setNodePreviewsByLocatorId,
setNodePreviewsByNodeId,
updateNodeImages,
refreshNodeOutputs,
@@ -489,7 +493,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
// Cleanup
revokePreviewsByExecutionId,
revokePreviewsByLocatorId,
revokeAllPreviews,
revokeSubgraphPreviews,
removeNodeOutputs,