diff --git a/apps/website/src/composables/useBannerImage.test.ts b/apps/website/src/composables/useBannerImage.test.ts
new file mode 100644
index 0000000000..bf23aca09e
--- /dev/null
+++ b/apps/website/src/composables/useBannerImage.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+
+import { useBannerImage } from './useBannerImage'
+
+describe('useBannerImage', () => {
+ it('shows the default banner when neither url is provided', () => {
+ const { showDefaultBanner, imgSrc } = useBannerImage({})
+ expect(showDefaultBanner.value).toBe(true)
+ expect(imgSrc.value).toBeUndefined()
+ })
+
+ it('prefers bannerUrl over iconUrl when both are provided', () => {
+ const { showDefaultBanner, imgSrc } = useBannerImage({
+ bannerUrl: 'https://example.com/banner.png',
+ iconUrl: 'https://example.com/icon.png'
+ })
+ expect(showDefaultBanner.value).toBe(false)
+ expect(imgSrc.value).toBe('https://example.com/banner.png')
+ })
+
+ it('falls back to iconUrl when bannerUrl is missing', () => {
+ const { imgSrc } = useBannerImage({
+ iconUrl: 'https://example.com/icon.png'
+ })
+ expect(imgSrc.value).toBe('https://example.com/icon.png')
+ })
+
+ it('reactively updates when sources change', () => {
+ const banner = ref(undefined)
+ const { showDefaultBanner, imgSrc } = useBannerImage({ bannerUrl: banner })
+
+ expect(showDefaultBanner.value).toBe(true)
+ banner.value = 'https://example.com/new.png'
+ expect(showDefaultBanner.value).toBe(false)
+ expect(imgSrc.value).toBe('https://example.com/new.png')
+ })
+
+ it('flips isImageError when onImageError is called', () => {
+ const { isImageError, onImageError } = useBannerImage({
+ bannerUrl: 'x'
+ })
+ expect(isImageError.value).toBe(false)
+ onImageError()
+ expect(isImageError.value).toBe(true)
+ })
+
+ it('exposes the default banner constant for consumers', () => {
+ const { DEFAULT_BANNER } = useBannerImage({})
+ expect(DEFAULT_BANNER).toBe('/assets/images/fallback-gradient-avatar.svg')
+ })
+})
diff --git a/apps/website/src/composables/useBannerImage.ts b/apps/website/src/composables/useBannerImage.ts
new file mode 100644
index 0000000000..0ceaf96aff
--- /dev/null
+++ b/apps/website/src/composables/useBannerImage.ts
@@ -0,0 +1,30 @@
+import { computed, ref, toValue } from 'vue'
+import type { MaybeRefOrGetter } from 'vue'
+
+const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
+
+interface UseBannerImageInput {
+ bannerUrl?: MaybeRefOrGetter
+ iconUrl?: MaybeRefOrGetter
+}
+
+export function useBannerImage({ bannerUrl, iconUrl }: UseBannerImageInput) {
+ const isImageError = ref(false)
+
+ const showDefaultBanner = computed(
+ () => !toValue(bannerUrl) && !toValue(iconUrl)
+ )
+ const imgSrc = computed(() => toValue(bannerUrl) || toValue(iconUrl))
+
+ function onImageError() {
+ isImageError.value = true
+ }
+
+ return {
+ DEFAULT_BANNER,
+ isImageError,
+ showDefaultBanner,
+ imgSrc,
+ onImageError
+ }
+}
diff --git a/apps/website/src/composables/useFilteredPacks.test.ts b/apps/website/src/composables/useFilteredPacks.test.ts
new file mode 100644
index 0000000000..f3073f561f
--- /dev/null
+++ b/apps/website/src/composables/useFilteredPacks.test.ts
@@ -0,0 +1,144 @@
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+
+import type { Pack, PackNode } from '../data/cloudNodes'
+
+import { useFilteredPacks } from './useFilteredPacks'
+import type { PackSortMode } from './useFilteredPacks'
+
+function pack(overrides: Partial = {}): Pack {
+ return {
+ id: overrides.id ?? 'pack',
+ displayName: overrides.displayName ?? 'Pack',
+ nodes: overrides.nodes ?? [],
+ downloads: overrides.downloads,
+ lastUpdated: overrides.lastUpdated,
+ ...overrides
+ }
+}
+
+function node(name: string, displayName: string): PackNode {
+ return { name, displayName, category: 'x' }
+}
+
+describe('useFilteredPacks', () => {
+ const packs: readonly Pack[] = [
+ pack({
+ id: 'a',
+ displayName: 'Alpha',
+ downloads: 100,
+ lastUpdated: '2025-01-01T00:00:00Z',
+ nodes: [node('aa', 'Aardvark')]
+ }),
+ pack({
+ id: 'b',
+ displayName: 'Beta',
+ downloads: 300,
+ lastUpdated: '2025-06-01T00:00:00Z',
+ nodes: [node('bb', 'Beaver'), node('bb2', 'Bumblebee')]
+ }),
+ pack({
+ id: 'c',
+ displayName: 'Gamma',
+ downloads: 200,
+ lastUpdated: '2025-03-01T00:00:00Z',
+ nodes: [
+ node('cc', 'Cat'),
+ node('cc2', 'Crocodile'),
+ node('cc3', 'Capybara')
+ ]
+ })
+ ]
+
+ it('sorts by downloads desc by default', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: '',
+ sortMode: 'downloads' as PackSortMode
+ })
+ expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
+ })
+
+ it('sorts most-nodes places highest count first', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: '',
+ sortMode: 'mostNodes' as PackSortMode
+ })
+ expect(filteredPacks.value.map((p) => p.id)).toEqual(['c', 'b', 'a'])
+ })
+
+ it('sorts A → Z by display name', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: '',
+ sortMode: 'az' as PackSortMode
+ })
+ expect(filteredPacks.value.map((p) => p.displayName)).toEqual([
+ 'Alpha',
+ 'Beta',
+ 'Gamma'
+ ])
+ })
+
+ it('sorts recently updated newest first', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: '',
+ sortMode: 'recentlyUpdated' as PackSortMode
+ })
+ expect(filteredPacks.value.map((p) => p.id)).toEqual(['b', 'c', 'a'])
+ })
+
+ it('treats invalid lastUpdated as 0', () => {
+ const broken = [
+ pack({ id: 'x', lastUpdated: 'nonsense' }),
+ pack({ id: 'y', lastUpdated: '2025-01-01T00:00:00Z' })
+ ]
+ const { filteredPacks } = useFilteredPacks({
+ packs: broken,
+ query: '',
+ sortMode: 'recentlyUpdated' as PackSortMode
+ })
+ expect(filteredPacks.value[0].id).toBe('y')
+ })
+
+ it('matches the search query against pack display names', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: 'beta',
+ sortMode: 'az' as PackSortMode
+ })
+ expect(filteredPacks.value.map((p) => p.id)).toEqual(['b'])
+ })
+
+ it('matches the search query against node display names', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: 'CAPYBARA',
+ sortMode: 'az' as PackSortMode
+ })
+ expect(filteredPacks.value.map((p) => p.id)).toEqual(['c'])
+ })
+
+ it('returns empty when nothing matches', () => {
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query: 'zzz-no-such-thing',
+ sortMode: 'az' as PackSortMode
+ })
+ expect(filteredPacks.value).toHaveLength(0)
+ })
+
+ it('reacts when the query ref changes', () => {
+ const query = ref('beta')
+ const { filteredPacks } = useFilteredPacks({
+ packs,
+ query,
+ sortMode: 'az' as PackSortMode
+ })
+ expect(filteredPacks.value).toHaveLength(1)
+ query.value = ''
+ expect(filteredPacks.value).toHaveLength(3)
+ })
+})
diff --git a/apps/website/src/composables/useFilteredPacks.ts b/apps/website/src/composables/useFilteredPacks.ts
new file mode 100644
index 0000000000..0c26400123
--- /dev/null
+++ b/apps/website/src/composables/useFilteredPacks.ts
@@ -0,0 +1,53 @@
+import { computed, toValue } from 'vue'
+import type { MaybeRefOrGetter } from 'vue'
+
+import type { Pack } from '../data/cloudNodes'
+
+export type PackSortMode = 'downloads' | 'mostNodes' | 'az' | 'recentlyUpdated'
+
+interface UseFilteredPacksInput {
+ packs: MaybeRefOrGetter
+ query: MaybeRefOrGetter
+ sortMode: MaybeRefOrGetter
+}
+
+function matchesQuery(pack: Pack, normalizedQuery: string): boolean {
+ if (pack.displayName.toLowerCase().includes(normalizedQuery)) return true
+ return pack.nodes.some((node) =>
+ node.displayName.toLowerCase().includes(normalizedQuery)
+ )
+}
+
+function safeTimestamp(value: string | undefined): number {
+ if (!value) return 0
+ const ts = Date.parse(value)
+ return Number.isNaN(ts) ? 0 : ts
+}
+
+export function useFilteredPacks(input: UseFilteredPacksInput) {
+ const filteredPacks = computed(() => {
+ const allPacks = toValue(input.packs)
+ const normalizedQuery = toValue(input.query).trim().toLowerCase()
+
+ const matching =
+ normalizedQuery.length === 0
+ ? [...allPacks]
+ : allPacks.filter((pack) => matchesQuery(pack, normalizedQuery))
+
+ const mode = toValue(input.sortMode)
+ if (mode === 'az') {
+ return matching.sort((a, b) => a.displayName.localeCompare(b.displayName))
+ }
+ if (mode === 'recentlyUpdated') {
+ return matching.sort(
+ (a, b) => safeTimestamp(b.lastUpdated) - safeTimestamp(a.lastUpdated)
+ )
+ }
+ if (mode === 'mostNodes') {
+ return matching.sort((a, b) => b.nodes.length - a.nodes.length)
+ }
+ return matching.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0))
+ })
+
+ return { filteredPacks }
+}
diff --git a/apps/website/src/composables/useHeroLogo.ts b/apps/website/src/composables/useHeroLogo.ts
new file mode 100644
index 0000000000..3a1e0b847a
--- /dev/null
+++ b/apps/website/src/composables/useHeroLogo.ts
@@ -0,0 +1,328 @@
+import type { Ref } from 'vue'
+import { onMounted, onUnmounted, ref } from 'vue'
+
+import * as THREE from 'three'
+import { SVGLoader } from 'three/addons/loaders/SVGLoader.js'
+
+import { prefersReducedMotion } from './useReducedMotion'
+
+const IMAGE_COUNT = 16
+const BASE_URL = 'https://media.comfy.org/website/homepage/hero-logo-seq'
+
+const SVG_MARKUP = ``
+
+interface HeroLogoConfig {
+ speed: number
+ tiltX: number
+ tiltZ: number
+ zoom: number
+ fov: number
+ logoColor: string
+ extrudeDepth: number
+ cursorTiltStrength: number
+ bgScale: number
+ slideDuration: number
+}
+
+const DEFAULTS: HeroLogoConfig = {
+ speed: 1,
+ tiltX: -0.1,
+ tiltZ: -0.1,
+ zoom: 7,
+ fov: 50,
+ logoColor: '#F2FF59',
+ extrudeDepth: 200,
+ cursorTiltStrength: 0.5,
+ bgScale: 0.8,
+ slideDuration: 0.4
+}
+
+function buildImageUrls(): string[] {
+ return Array.from({ length: IMAGE_COUNT }, (_, i) => {
+ const index = String(i).padStart(5, '0')
+ return `${BASE_URL}/image_sequence_${index}.webp`
+ })
+}
+
+function parseShapes(): THREE.Shape[] {
+ const loader = new SVGLoader()
+ const svgData = loader.parse(SVG_MARKUP)
+ const shapes: THREE.Shape[] = []
+ svgData.paths.forEach((path) => {
+ shapes.push(...SVGLoader.createShapes(path))
+ })
+ return shapes
+}
+
+function loadTextures(urls: string[]): Promise {
+ return Promise.all(
+ urls.map(
+ (url) =>
+ new Promise((resolve) => {
+ const img = new Image()
+ img.crossOrigin = 'anonymous'
+ img.onload = () => {
+ const tex = new THREE.Texture(img)
+ tex.needsUpdate = true
+ tex.colorSpace = THREE.SRGBColorSpace
+ resolve(tex)
+ }
+ img.onerror = () => resolve(null)
+ img.src = url
+ })
+ )
+ ).then((results) => results.filter((t): t is THREE.Texture => t !== null))
+}
+
+export function useHeroLogo(
+ containerRef: Ref,
+ config: Partial = {}
+) {
+ const cfg = { ...DEFAULTS, ...config }
+ const loaded = ref(false)
+ let cleanup: (() => void) | undefined
+
+ onMounted(async () => {
+ try {
+ const container = containerRef.value
+ if (!container || prefersReducedMotion()) return
+
+ const { width, height } = container.getBoundingClientRect()
+
+ const renderer = new THREE.WebGLRenderer({
+ antialias: true,
+ stencil: true,
+ alpha: true
+ })
+ renderer.setSize(width, height)
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
+ renderer.outputColorSpace = THREE.SRGBColorSpace
+ renderer.domElement.style.position = 'absolute'
+ renderer.domElement.style.inset = '0'
+ renderer.domElement.style.width = '100%'
+ renderer.domElement.style.height = '100%'
+ renderer.domElement.style.opacity = '0'
+ renderer.domElement.setAttribute('aria-hidden', 'true')
+ container.appendChild(renderer.domElement)
+
+ let disposed = false
+ const teardowns: Array<() => void> = []
+ cleanup = () => {
+ disposed = true
+ teardowns.forEach((fn) => fn())
+ }
+ teardowns.push(() => {
+ renderer.dispose()
+ renderer.domElement.remove()
+ })
+
+ const scene = new THREE.Scene()
+ const camera = new THREE.PerspectiveCamera(
+ cfg.fov,
+ width / height,
+ 0.1,
+ 1000
+ )
+ camera.position.z = cfg.zoom
+
+ // SVG shape
+ const shapes = parseShapes()
+ const tempGeo = new THREE.ShapeGeometry(shapes)
+ tempGeo.computeBoundingBox()
+ const bb = tempGeo.boundingBox!
+ const cx = (bb.max.x + bb.min.x) / 2
+ const cy = (bb.max.y + bb.min.y) / 2
+ const scaleFactor = 3 / (bb.max.y - bb.min.y)
+ tempGeo.dispose()
+
+ // Image sequence textures — load first frame eagerly, rest lazily
+ const urls = buildImageUrls()
+ const textures = await loadTextures(urls.slice(0, 1))
+ if (disposed) return
+
+ renderer.domElement.style.opacity = '1'
+ loaded.value = true
+
+ loadTextures(urls.slice(1)).then((rest) => {
+ if (!disposed) textures.push(...rest)
+ })
+
+ // Background plane (stencil read)
+ const bgPlaneGeo = new THREE.PlaneGeometry(14, 14)
+ const bgPlaneMat = new THREE.MeshBasicMaterial({
+ transparent: true,
+ opacity: 1,
+ map: textures[0] ?? null,
+ depthTest: false,
+ depthWrite: false,
+ stencilWrite: true,
+ stencilFunc: THREE.EqualStencilFunc,
+ stencilRef: 1,
+ stencilFail: THREE.KeepStencilOp,
+ stencilZFail: THREE.KeepStencilOp,
+ stencilZPass: THREE.KeepStencilOp
+ })
+ const bgPlane = new THREE.Mesh(bgPlaneGeo, bgPlaneMat)
+ bgPlane.renderOrder = 1
+ bgPlane.scale.set(cfg.bgScale, cfg.bgScale, 1)
+ scene.add(bgPlane)
+
+ // Logo group
+ const group = new THREE.Group()
+ scene.add(group)
+
+ const s = scaleFactor
+ const depth = cfg.extrudeDepth
+
+ // Front face
+ const shapeGeo = new THREE.ShapeGeometry(shapes)
+ shapeGeo.translate(-cx, -cy, 0)
+ shapeGeo.scale(s, -s, s)
+ const shapeMat = new THREE.MeshBasicMaterial({
+ color: cfg.logoColor,
+ side: THREE.DoubleSide,
+ depthTest: false,
+ depthWrite: false,
+ transparent: true
+ })
+ const logoMesh = new THREE.Mesh(shapeGeo, shapeMat)
+ logoMesh.renderOrder = 2
+ group.add(logoMesh)
+
+ // Extrusion stencil mask
+ const extrudeGeo = new THREE.ExtrudeGeometry(shapes, {
+ depth,
+ bevelEnabled: false
+ })
+ extrudeGeo.translate(-cx, -cy, -depth)
+ extrudeGeo.scale(s, -s, s)
+ const extrudeMat = new THREE.MeshBasicMaterial({
+ colorWrite: false,
+ depthWrite: true,
+ depthTest: true,
+ stencilWrite: true,
+ stencilRef: 1,
+ stencilFunc: THREE.AlwaysStencilFunc,
+ stencilZPass: THREE.ReplaceStencilOp,
+ stencilFail: THREE.KeepStencilOp,
+ stencilZFail: THREE.KeepStencilOp,
+ side: THREE.DoubleSide
+ })
+ const extrudeMesh = new THREE.Mesh(extrudeGeo, extrudeMat)
+ extrudeMesh.renderOrder = 0
+ group.add(extrudeMesh)
+
+ // Interaction
+ let isDragging = false
+ let previousX = 0
+ let dragVelocity = 0
+ let currentTiltX = 0
+ let currentTiltY = 0
+ let pointerX = 0
+ let pointerY = 0
+ let rotationT = 0
+ let currentSlide = 0
+ let slideTimer = 0
+ let animationId = 0
+
+ function onMouseMove(e: MouseEvent) {
+ pointerX = (e.clientX / window.innerWidth) * 2 - 1
+ pointerY = (e.clientY / window.innerHeight) * 2 - 1
+ }
+
+ function onPointerDown(e: PointerEvent) {
+ isDragging = true
+ dragVelocity = 0
+ previousX = e.clientX
+ }
+
+ function onPointerMove(e: PointerEvent) {
+ if (!isDragging) return
+ dragVelocity = (e.clientX - previousX) * 0.005
+ rotationT += dragVelocity
+ previousX = e.clientX
+ }
+
+ function onPointerUp() {
+ isDragging = false
+ }
+
+ function onResize() {
+ const rect = container!.getBoundingClientRect()
+ camera.aspect = rect.width / rect.height
+ camera.updateProjectionMatrix()
+ renderer.setSize(rect.width, rect.height)
+ }
+
+ window.addEventListener('mousemove', onMouseMove)
+ renderer.domElement.addEventListener('pointerdown', onPointerDown)
+ window.addEventListener('pointermove', onPointerMove)
+ window.addEventListener('pointerup', onPointerUp)
+ window.addEventListener('resize', onResize)
+
+ const clock = new THREE.Clock()
+
+ function animate() {
+ if (disposed) return
+ animationId = requestAnimationFrame(animate)
+ const dt = clock.getDelta()
+
+ if (!isDragging && Math.abs(dragVelocity) > 0.0001) {
+ dragVelocity *= 0.95
+ rotationT += dragVelocity
+ } else if (!isDragging) {
+ dragVelocity = 0
+ }
+
+ rotationT += cfg.speed * dt
+
+ currentTiltX += (pointerY - currentTiltX) * 0.08
+ currentTiltY += (pointerX - currentTiltY) * 0.08
+
+ group.rotation.y = rotationT % (Math.PI * 2)
+ group.rotation.x = cfg.tiltX - currentTiltX * cfg.cursorTiltStrength
+ group.rotation.z = cfg.tiltZ
+
+ if (textures.length > 1) {
+ slideTimer += dt
+ if (slideTimer >= cfg.slideDuration) {
+ slideTimer = 0
+ currentSlide = (currentSlide + 1) % textures.length
+ bgPlaneMat.map = textures[currentSlide]
+ bgPlaneMat.needsUpdate = true
+ }
+ }
+
+ renderer.render(scene, camera)
+ }
+
+ animate()
+
+ teardowns.push(
+ () => cancelAnimationFrame(animationId),
+ () => window.removeEventListener('mousemove', onMouseMove),
+ () =>
+ renderer.domElement.removeEventListener('pointerdown', onPointerDown),
+ () => window.removeEventListener('pointermove', onPointerMove),
+ () => window.removeEventListener('pointerup', onPointerUp),
+ () => window.removeEventListener('resize', onResize),
+ () => bgPlaneGeo.dispose(),
+ () => bgPlaneMat.dispose(),
+ () => shapeGeo.dispose(),
+ () => shapeMat.dispose(),
+ () => extrudeGeo.dispose(),
+ () => extrudeMat.dispose(),
+ () => textures.forEach((tex) => tex.dispose())
+ )
+ } catch (err) {
+ console.error('[useHeroLogo] initialization failed:', err)
+ cleanup?.()
+ }
+ })
+
+ onUnmounted(() => {
+ cleanup?.()
+ })
+
+ return { loaded }
+}
diff --git a/apps/website/src/composables/useNodesByCategory.test.ts b/apps/website/src/composables/useNodesByCategory.test.ts
new file mode 100644
index 0000000000..642c598fb5
--- /dev/null
+++ b/apps/website/src/composables/useNodesByCategory.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+
+import type { PackNode } from '../data/cloudNodes'
+
+import { useNodesByCategory } from './useNodesByCategory'
+
+function node(name: string, displayName: string, category: string): PackNode {
+ return { name, displayName, category }
+}
+
+describe('useNodesByCategory', () => {
+ it('groups nodes by category', () => {
+ const { groupedNodes } = useNodesByCategory(() => [
+ node('A', 'A', 'cat-1'),
+ node('B', 'B', 'cat-2'),
+ node('C', 'C', 'cat-1')
+ ])
+ expect(groupedNodes.value).toHaveLength(2)
+ expect(groupedNodes.value[0]).toMatchObject({
+ category: 'cat-1',
+ nodes: [
+ expect.objectContaining({ name: 'A' }),
+ expect.objectContaining({ name: 'C' })
+ ]
+ })
+ })
+
+ it('sorts nodes alphabetically by display name within a category', () => {
+ const { groupedNodes } = useNodesByCategory(() => [
+ node('z', 'Zulu', 'x'),
+ node('a', 'Alpha', 'x'),
+ node('m', 'Mike', 'x')
+ ])
+ expect(groupedNodes.value[0].nodes.map((n) => n.displayName)).toEqual([
+ 'Alpha',
+ 'Mike',
+ 'Zulu'
+ ])
+ })
+
+ it('sorts categories alphabetically', () => {
+ const { groupedNodes } = useNodesByCategory(() => [
+ node('a', 'A', 'beta'),
+ node('b', 'B', 'alpha'),
+ node('c', 'C', 'gamma')
+ ])
+ expect(groupedNodes.value.map((g) => g.category)).toEqual([
+ 'alpha',
+ 'beta',
+ 'gamma'
+ ])
+ })
+
+ it('falls back to a placeholder for missing categories', () => {
+ const { groupedNodes } = useNodesByCategory(() => [node('a', 'A', '')])
+ expect(groupedNodes.value[0].category).toBe('—')
+ })
+
+ it('reacts to ref changes', () => {
+ const nodes = ref([node('a', 'A', 'x')])
+ const { groupedNodes } = useNodesByCategory(nodes)
+ expect(groupedNodes.value).toHaveLength(1)
+
+ nodes.value = [node('a', 'A', 'x'), node('b', 'B', 'y')]
+ expect(groupedNodes.value).toHaveLength(2)
+ })
+})
diff --git a/apps/website/src/composables/useNodesByCategory.ts b/apps/website/src/composables/useNodesByCategory.ts
new file mode 100644
index 0000000000..f2bf407582
--- /dev/null
+++ b/apps/website/src/composables/useNodesByCategory.ts
@@ -0,0 +1,40 @@
+import { computed, toValue } from 'vue'
+import type { MaybeRefOrGetter } from 'vue'
+
+import type { PackNode } from '../data/cloudNodes'
+
+const UNCATEGORIZED = '—'
+
+interface NodeCategoryGroup {
+ category: string
+ nodes: PackNode[]
+}
+
+export function useNodesByCategory(
+ nodes: MaybeRefOrGetter
+) {
+ const groupedNodes = computed(() => {
+ const byCategory = new Map()
+
+ for (const node of toValue(nodes)) {
+ const category = node.category || UNCATEGORIZED
+ const existing = byCategory.get(category)
+ if (existing) {
+ existing.push(node)
+ continue
+ }
+ byCategory.set(category, [node])
+ }
+
+ return [...byCategory.entries()]
+ .map(([category, items]) => ({
+ category,
+ nodes: [...items].sort((a, b) =>
+ a.displayName.localeCompare(b.displayName)
+ )
+ }))
+ .sort((a, b) => a.category.localeCompare(b.category))
+ })
+
+ return { groupedNodes }
+}
diff --git a/apps/website/src/config/demos.ts b/apps/website/src/config/demos.ts
new file mode 100644
index 0000000000..980a7f1dee
--- /dev/null
+++ b/apps/website/src/config/demos.ts
@@ -0,0 +1,95 @@
+import type { TranslationKey } from '../i18n/translations'
+
+interface Demo {
+ readonly slug: string
+ readonly arcadeId: string
+ readonly category: TranslationKey
+ readonly title: TranslationKey
+ readonly description: TranslationKey
+ readonly ogImage: string
+ readonly thumbnail: string
+ readonly estimatedTime: TranslationKey
+ readonly durationIso: string
+ readonly difficulty: 'beginner' | 'intermediate' | 'advanced'
+ readonly tags: readonly string[]
+ readonly transcript?: TranslationKey
+ readonly publishedDate: string
+ readonly modifiedDate: string
+ /**
+ * Width / height of the Arcade demo's source recording (e.g. 1.93 for a
+ * landscape screencast). Sizes the embed container to match so rounded
+ * corners hug the content instead of empty letterbox space. Source from
+ * Arcade's `_serializablePublicFlow.aspectRatio` (which is height/width —
+ * invert it). Defaults to 16/9 if omitted.
+ */
+ readonly aspectRatio?: number
+}
+
+export const demos: readonly Demo[] = [
+ {
+ slug: 'image-to-video',
+ arcadeId: 'F3CTalnGnR4R0qJIVMNX',
+ category: 'demos.category.templates',
+ title: 'demos.image-to-video.title',
+ description: 'demos.image-to-video.description',
+ transcript: 'demos.image-to-video.transcript',
+ ogImage: '/images/demos/image-to-video-og.png',
+ thumbnail: '/images/demos/image-to-video-thumb.webp',
+ estimatedTime: 'demos.duration.2min',
+ durationIso: 'PT2M',
+ difficulty: 'beginner',
+ tags: ['templates', 'image', 'video'],
+ publishedDate: '2026-04-19',
+ modifiedDate: '2026-04-19',
+ aspectRatio: 1.931
+ },
+ {
+ slug: 'workflow-templates',
+ arcadeId: 'KhqcXDElnFWklo7ACBqE',
+ category: 'demos.category.gettingStarted',
+ title: 'demos.workflow-templates.title',
+ description: 'demos.workflow-templates.description',
+ transcript: 'demos.workflow-templates.transcript',
+ ogImage: '/images/demos/workflow-templates-og.png',
+ thumbnail: '/images/demos/workflow-templates-thumb.webp',
+ estimatedTime: 'demos.duration.2min',
+ durationIso: 'PT2M',
+ difficulty: 'beginner',
+ tags: ['getting-started', 'templates', 'workflow'],
+ publishedDate: '2026-04-19',
+ modifiedDate: '2026-04-19',
+ aspectRatio: 1.931
+ },
+ {
+ slug: 'community-workflows',
+ arcadeId: 'mqZh17oWDuWIyhK0xwEV',
+ category: 'demos.category.gettingStarted',
+ title: 'demos.community-workflows.title',
+ description: 'demos.community-workflows.description',
+ transcript: 'demos.community-workflows.transcript',
+ ogImage: '/images/demos/community-workflows-og.png',
+ thumbnail: '/images/demos/community-workflows-thumb.webp',
+ estimatedTime: 'demos.duration.2min',
+ durationIso: 'PT2M',
+ difficulty: 'beginner',
+ tags: ['getting-started', 'community', 'workflow', 'hub'],
+ publishedDate: '2026-05-04',
+ modifiedDate: '2026-05-04',
+ aspectRatio: 1.931
+ }
+]
+
+export function getDemoBySlug(slug: string): Demo | undefined {
+ return demos.find((demo) => demo.slug === slug)
+}
+
+export function getNextDemo(slug: string): Demo {
+ if (demos.length === 0) {
+ throw new Error('No demos configured')
+ }
+ const index = demos.findIndex((demo) => demo.slug === slug)
+ if (index === -1) {
+ throw new Error(`Unknown demo slug: ${slug}`)
+ }
+ return demos[(index + 1) % demos.length]
+}
diff --git a/apps/website/src/config/features.ts b/apps/website/src/config/features.ts
new file mode 100644
index 0000000000..2247d75941
--- /dev/null
+++ b/apps/website/src/config/features.ts
@@ -0,0 +1 @@
+export const SHOW_FREE_TIER = false
diff --git a/apps/website/src/config/generated-models.json b/apps/website/src/config/generated-models.json
new file mode 100644
index 0000000000..7bcf654a75
--- /dev/null
+++ b/apps/website/src/config/generated-models.json
@@ -0,0 +1,1945 @@
+[
+ {
+ "slug": "kling-ai",
+ "name": "Kling AI",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 11,
+ "displayName": "Kling AI"
+ },
+ {
+ "slug": "openai-dall-e",
+ "name": "OpenAI DALL-E",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 10,
+ "displayName": "OpenAI DALL-E"
+ },
+ {
+ "slug": "vidu",
+ "name": "Vidu",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 8,
+ "displayName": "Vidu"
+ },
+ {
+ "slug": "seedance-bytedance",
+ "name": "Seedance (ByteDance)",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 7,
+ "displayName": "Seedance (ByteDance)"
+ },
+ {
+ "slug": "stability-ai",
+ "name": "Stability AI",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 7,
+ "displayName": "Stability AI"
+ },
+ {
+ "slug": "wan-api",
+ "name": "Wan (API)",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 6,
+ "displayName": "Wan (API)"
+ },
+ {
+ "slug": "flux-api",
+ "name": "Flux (API)",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 5,
+ "displayName": "Flux (API)"
+ },
+ {
+ "slug": "runway",
+ "name": "Runway",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 5,
+ "displayName": "Runway"
+ },
+ {
+ "slug": "tripo-3d",
+ "name": "Tripo 3D",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 5,
+ "displayName": "Tripo 3D"
+ },
+ {
+ "slug": "grok-image",
+ "name": "Grok Image",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 4,
+ "displayName": "Grok Image"
+ },
+ {
+ "slug": "luma-dream-machine",
+ "name": "Luma Dream Machine",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 4,
+ "displayName": "Luma Dream Machine"
+ },
+ {
+ "slug": "moonvalley",
+ "name": "Moonvalley",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 4,
+ "displayName": "Moonvalley"
+ },
+ {
+ "slug": "hailuo-minimax",
+ "name": "Hailuo MiniMax",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 3,
+ "displayName": "Hailuo MiniMax"
+ },
+ {
+ "slug": "magnific-ai",
+ "name": "Magnific AI",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 3,
+ "displayName": "Magnific AI"
+ },
+ {
+ "slug": "meshy-ai",
+ "name": "Meshy AI",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 3,
+ "displayName": "Meshy AI"
+ },
+ {
+ "slug": "pixverse",
+ "name": "Pixverse",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 3,
+ "displayName": "Pixverse"
+ },
+ {
+ "slug": "recraft",
+ "name": "Recraft",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 3,
+ "displayName": "Recraft"
+ },
+ {
+ "slug": "rodin-3d",
+ "name": "Rodin 3D",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 3,
+ "displayName": "Rodin 3D"
+ },
+ {
+ "slug": "bria-ai",
+ "name": "Bria AI",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 2,
+ "displayName": "Bria AI"
+ },
+ {
+ "slug": "gemini-image",
+ "name": "Gemini Image",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 2,
+ "displayName": "Gemini Image"
+ },
+ {
+ "slug": "hunyuan-3d",
+ "name": "Hunyuan 3D",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 2,
+ "displayName": "Hunyuan 3D"
+ },
+ {
+ "slug": "ltxv-api",
+ "name": "LTX Video (API)",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 2,
+ "displayName": "LTX Video (API)"
+ },
+ {
+ "slug": "topaz-labs",
+ "name": "Topaz Labs",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 2,
+ "displayName": "Topaz Labs"
+ },
+ {
+ "slug": "wavespeed",
+ "name": "Wavespeed",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 2,
+ "displayName": "Wavespeed"
+ },
+ {
+ "slug": "ideogram",
+ "name": "Ideogram",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 1,
+ "displayName": "Ideogram"
+ },
+ {
+ "slug": "nano-banana",
+ "name": "Nano Banana",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 1,
+ "displayName": "Nano Banana"
+ },
+ {
+ "slug": "veo-2",
+ "name": "Veo 2",
+ "huggingFaceUrl": "",
+ "directory": "partner_nodes",
+ "workflowCount": 1,
+ "displayName": "Veo 2"
+ },
+ {
+ "slug": "umt5-xxl-fp8-e4m3fn-scaled",
+ "name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 34,
+ "displayName": "Umt5 Xxl FP8 e4m3fn scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp"
+ },
+ {
+ "slug": "wan-2-1-vae",
+ "name": "wan_2.1_vae.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/vae/wan_2.1_vae.safetensors",
+ "directory": "vae",
+ "workflowCount": 29,
+ "displayName": "Wan 2.1 Vae",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp"
+ },
+ {
+ "slug": "ae",
+ "name": "ae.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors",
+ "directory": "vae",
+ "workflowCount": 26,
+ "displayName": "Ae",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/z-image/z-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/01_get_started_text_to_image-1.webp"
+ },
+ {
+ "slug": "qwen-2-5-vl-7b-fp8-scaled",
+ "name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 25,
+ "displayName": "Qwen 2.5 Vl 7b FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/02_qwen_Image_edit_subgraphed-1.webp"
+ },
+ {
+ "slug": "qwen-image-vae",
+ "name": "qwen_image_vae.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors",
+ "directory": "vae",
+ "workflowCount": 19,
+ "displayName": "Qwen Image Vae",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/02_qwen_Image_edit_subgraphed-1.webp"
+ },
+ {
+ "slug": "clip-l",
+ "name": "clip_l.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/clip_l.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 14,
+ "displayName": "Clip L",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-fill-dev",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_krea_dev-1.webp"
+ },
+ {
+ "slug": "t5xxl-fp16",
+ "name": "t5xxl_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 12,
+ "displayName": "T5xxl FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-fill-dev",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_krea_dev-1.webp"
+ },
+ {
+ "slug": "clip-vision-h",
+ "name": "clip_vision_h.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/clip_vision/clip_vision_h.safetensors",
+ "directory": "clip_vision",
+ "workflowCount": 12,
+ "displayName": "Clip Vision H",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-animate",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chrono_edit_14B-1.webp"
+ },
+ {
+ "slug": "flux2-vae",
+ "name": "flux2-vae.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/vae/flux2-vae.safetensors",
+ "directory": "vae",
+ "workflowCount": 9,
+ "displayName": "Flux2 Vae",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2-1.webp"
+ },
+ {
+ "slug": "qwen-3-4b",
+ "name": "qwen_3_4b.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 8,
+ "displayName": "Qwen 3 4b",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/z-image/z-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/01_get_started_text_to_image-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-fp8-e4m3fn",
+ "name": "qwen_image_edit_2509_fp8_e4m3fn.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2509_fp8_e4m3fn.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 8,
+ "displayName": "Qwen Image Edit 2509 FP8 e4m3fn",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/02_qwen_Image_edit_subgraphed-1.webp"
+ },
+ {
+ "slug": "t5xxl-fp8-e4m3fn-scaled",
+ "name": "t5xxl_fp8_e4m3fn_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/comfyanonymous/flux_text_encoders/resolve/main/t5xxl_fp8_e4m3fn_scaled.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 8,
+ "displayName": "T5xxl FP8 e4m3fn scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_kontext_dev_basic-1.webp",
+ "canonicalSlug": "t5xxl-fp16"
+ },
+ {
+ "slug": "wan2-2-i2v-lightx2v-4steps-lora-v1-high-noise",
+ "name": "wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors",
+ "directory": "loras",
+ "workflowCount": 7,
+ "displayName": "Wan2.2 I2v Lightx2v 4steps Lora V1 High Noise",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp"
+ },
+ {
+ "slug": "wan2-2-i2v-lightx2v-4steps-lora-v1-low-noise",
+ "name": "wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors",
+ "directory": "loras",
+ "workflowCount": 7,
+ "displayName": "Wan2.2 I2v Lightx2v 4steps Lora V1 Low Noise",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp"
+ },
+ {
+ "slug": "gemma-3-12b-it-fp4-mixed",
+ "name": "gemma_3_12B_it_fp4_mixed.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/ltx-2/resolve/main/split_files/text_encoders/gemma_3_12B_it_fp4_mixed.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 7,
+ "displayName": "Gemma 3 12B It FP4 mixed",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp"
+ },
+ {
+ "slug": "ltx-2-spatial-upscaler-x2-1-0",
+ "name": "ltx-2-spatial-upscaler-x2-1.0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-spatial-upscaler-x2-1.0.safetensors",
+ "directory": "latent_upscale_models",
+ "workflowCount": 7,
+ "displayName": "Ltx 2 Spatial Upscaler X2 1.0",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-lightning-4steps-v1-0-bf16",
+ "name": "Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-4steps-V1.0-bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 6,
+ "displayName": "Qwen Image Edit 2509 Lightning 4steps V1.0 BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/02_qwen_Image_edit_subgraphed-1.webp"
+ },
+ {
+ "slug": "wan2-1-vace-1-3b-fp16",
+ "name": "wan2.1_vace_1.3B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_vace_1.3B_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 6,
+ "displayName": "Wan2.1 Vace 1.3B FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp"
+ },
+ {
+ "slug": "wan21-causvid-bidirect2-t2v-1-3b-lora-rank32",
+ "name": "Wan21_CausVid_bidirect2_T2V_1_3B_lora_rank32.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan21_CausVid_bidirect2_T2V_1_3B_lora_rank32.safetensors",
+ "directory": "loras",
+ "workflowCount": 6,
+ "displayName": "Wan21 CausVid Bidirect2 T2V 1 3B Lora Rank32",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp"
+ },
+ {
+ "slug": "umt5-xxl-fp16",
+ "name": "umt5_xxl_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 6,
+ "displayName": "Umt5 Xxl FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp",
+ "canonicalSlug": "umt5-xxl-fp8-e4m3fn-scaled"
+ },
+ {
+ "slug": "wan2-1-vace-14b-fp16",
+ "name": "wan2.1_vace_14B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_vace_14B_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 6,
+ "displayName": "Wan2.1 Vace 14B FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp"
+ },
+ {
+ "slug": "wan21-causvid-14b-t2v-lora-rank32",
+ "name": "Wan21_CausVid_14B_T2V_lora_rank32.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan21_CausVid_14B_T2V_lora_rank32.safetensors",
+ "directory": "loras",
+ "workflowCount": 6,
+ "displayName": "Wan21 CausVid 14B T2V Lora Rank32",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/vace",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan_vace_14B_ref2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-i2v-high-noise-14b-fp8-scaled",
+ "name": "wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_high_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 5,
+ "displayName": "Wan2.2 I2v High Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp"
+ },
+ {
+ "slug": "wan2-2-i2v-low-noise-14b-fp8-scaled",
+ "name": "wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_i2v_low_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 5,
+ "displayName": "Wan2.2 I2v Low Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/03_video_wan2_2_14B_i2v_subgraphed-1.webp"
+ },
+ {
+ "slug": "vae-ft-mse-840000-ema-pruned",
+ "name": "vae-ft-mse-840000-ema-pruned.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/stabilityai/sd-vae-ft-mse-original/resolve/main/vae-ft-mse-840000-ema-pruned.safetensors",
+ "directory": "vae",
+ "workflowCount": 5,
+ "displayName": "Vae Ft Mse 840000 Ema Pruned",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_depth_lora_example-1.webp"
+ },
+ {
+ "slug": "lotus-depth-d-v1-1",
+ "name": "lotus-depth-d-v1-1.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/lotus/resolve/main/lotus-depth-d-v1-1.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 5,
+ "displayName": "Lotus Depth D V1 1",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_depth_lora_example-1.webp"
+ },
+ {
+ "slug": "clip-g-hidream",
+ "name": "clip_g_hidream.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/text_encoders/clip_g_hidream.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 5,
+ "displayName": "Clip G Hidream",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_1-1.webp"
+ },
+ {
+ "slug": "clip-l-hidream",
+ "name": "clip_l_hidream.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/text_encoders/clip_l_hidream.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 5,
+ "displayName": "Clip L Hidream",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_1-1.webp"
+ },
+ {
+ "slug": "llama-3-1-8b-instruct-fp8-scaled",
+ "name": "llama_3.1_8b_instruct_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/text_encoders/llama_3.1_8b_instruct_fp8_scaled.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 5,
+ "displayName": "Llama 3.1 8b Instruct FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_1-1.webp"
+ },
+ {
+ "slug": "qwen-image-lightning-4steps-v1-0",
+ "name": "Qwen-Image-Lightning-4steps-V1.0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-4steps-V1.0.safetensors",
+ "directory": "loras",
+ "workflowCount": 5,
+ "displayName": "Qwen Image Lightning 4steps V1.0",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_Image_2512-1.webp"
+ },
+ {
+ "slug": "qwen-image-fp8-e4m3fn",
+ "name": "qwen_image_fp8_e4m3fn.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_fp8_e4m3fn.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 5,
+ "displayName": "Qwen Image FP8 e4m3fn",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image-1.webp"
+ },
+ {
+ "slug": "lightx2v-i2v-14b-480p-cfg-step-distill-rank64-bf16",
+ "name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Lightx2v/lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 5,
+ "displayName": "Lightx2v I2V 14B 480p Cfg Step Distill Rank64 BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-animate",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_humo-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-dev-fp8",
+ "name": "ltx-2-19b-dev-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-dev-fp8.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 5,
+ "displayName": "Ltx 2 19b Dev FP8",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-distilled-lora-384",
+ "name": "ltx-2-19b-distilled-lora-384.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-distilled-lora-384.safetensors",
+ "directory": "loras",
+ "workflowCount": 5,
+ "displayName": "Ltx 2 19b Distilled Lora 384",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-distilled",
+ "name": "ltx-2-19b-distilled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2/resolve/main/ltx-2-19b-distilled.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 5,
+ "displayName": "Ltx 2 19b Distilled",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp"
+ },
+ {
+ "slug": "z-image-turbo-bf16",
+ "name": "z_image_turbo_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 4,
+ "displayName": "Z Image Turbo BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/z-image/z-image-turbo",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/01_get_started_text_to_image-1.webp"
+ },
+ {
+ "slug": "ace-step-v1-3-5b",
+ "name": "ace_step_v1_3.5b.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/ACE-Step_ComfyUI_repackaged/resolve/main/all_in_one/ace_step_v1_3.5b.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 4,
+ "displayName": "Ace Step V1 3.5b",
+ "docsUrl": "https://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/05_audio_ace_step_1_t2a_song_subgraphed-1.webp"
+ },
+ {
+ "slug": "sd3-5-large-fp8-scaled",
+ "name": "sd3.5_large_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-3.5-fp8/resolve/main/sd3.5_large_fp8_scaled.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 4,
+ "displayName": "Sd3.5 Large FP8 scaled",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sd3.5_large_blur-1.webp"
+ },
+ {
+ "slug": "sd-xl-base-1-0",
+ "name": "sd_xl_base_1.0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 4,
+ "displayName": "Sd Xl Base 1.0",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sdxl/",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sdxl_refiner_prompt_example-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-lora-camera-control-dolly-left",
+ "name": "ltx-2-19b-lora-camera-control-dolly-left.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2-19b-LoRA-Camera-Control-Dolly-Left/resolve/main/ltx-2-19b-lora-camera-control-dolly-left.safetensors",
+ "directory": "loras",
+ "workflowCount": 4,
+ "displayName": "Ltx 2 19b Lora Camera Control Dolly Left",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_i2v-1.webp"
+ },
+ {
+ "slug": "sigclip-vision-patch14-384",
+ "name": "sigclip_vision_patch14_384.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/sigclip_vision_384/resolve/main/sigclip_vision_patch14_384.safetensors",
+ "directory": "clip_vision",
+ "workflowCount": 3,
+ "displayName": "Sigclip Vision Patch14 384",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-uso",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_dev_uso_reference_image_gen-1.webp"
+ },
+ {
+ "slug": "flux1-dev",
+ "name": "flux1-dev.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 3,
+ "displayName": "Flux1 Dev",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-text-to-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_dev_checkpoint_example-1.webp"
+ },
+ {
+ "slug": "hunyuan-video-vae-bf16",
+ "name": "hunyuan_video_vae_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/vae/hunyuan_video_vae_bf16.safetensors?download=true",
+ "directory": "vae",
+ "workflowCount": 3,
+ "displayName": "Hunyuan Video Vae BF16",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hunyuan_video_text_to_video-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2511-bf16",
+ "name": "qwen_image_edit_2511_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_2511_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 3,
+ "displayName": "Qwen Image Edit 2511 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image-qwen_image_edit_2511-lora-inflation-1.webp"
+ },
+ {
+ "slug": "flux2-dev-fp8mixed",
+ "name": "flux2_dev_fp8mixed.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/diffusion_models/flux2_dev_fp8mixed.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 3,
+ "displayName": "Flux2 Dev fp8mixed",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2-1.webp"
+ },
+ {
+ "slug": "qwen-3-8b-fp8mixed",
+ "name": "qwen_3_8b_fp8mixed.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 3,
+ "displayName": "Qwen 3 8b fp8mixed",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_9b_base-1.webp"
+ },
+ {
+ "slug": "wan2-2-vae",
+ "name": "wan2.2_vae.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/vae/wan2.2_vae.safetensors",
+ "directory": "vae",
+ "workflowCount": 3,
+ "displayName": "Wan2.2 Vae",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_fun_control-1.webp"
+ },
+ {
+ "slug": "hunyuan-3d-v2-1",
+ "name": "hunyuan_3d_v2.1.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/hunyuan3D_2.1_repackaged/resolve/main/hunyuan_3d_v2.1.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 2,
+ "displayName": "Hunyuan 3d V2.1",
+ "docsUrl": "https://docs.comfy.org/tutorials/3d/hunyuan3D-2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/04_hunyuan_3d_2.1_subgraphed-1.webp"
+ },
+ {
+ "slug": "flux1-dev-fp8",
+ "name": "flux1-dev-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/flux1-dev-fp8.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 2,
+ "displayName": "Flux1 Dev FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-uso",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_dev_uso_reference_image_gen-1.webp"
+ },
+ {
+ "slug": "flux1-fill-dev",
+ "name": "flux1-fill-dev.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/split_files/diffusion_models/flux1-fill-dev.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Flux1 Fill Dev",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-fill-dev",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_fill_inpaint_example-1.webp"
+ },
+ {
+ "slug": "mistral-3-small-flux2-bf16",
+ "name": "mistral_3_small_flux2_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_bf16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 2,
+ "displayName": "Mistral 3 Small Flux2 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2-1.webp"
+ },
+ {
+ "slug": "flux-2-klein-base-9b-fp8",
+ "name": "flux-2-klein-base-9b-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/black-forest-labs/FLUX.2-klein-base-9b-fp8/resolve/main/flux-2-klein-base-9b-fp8.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Flux 2 Klein Base 9b FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_9b_base-1.webp"
+ },
+ {
+ "slug": "flux-2-klein-9b-fp8",
+ "name": "flux-2-klein-9b-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/black-forest-labs/FLUX.2-klein-9b-fp8/resolve/main/flux-2-klein-9b-fp8.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Flux 2 Klein 9b FP8",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_9b_distilled-1.webp"
+ },
+ {
+ "slug": "qwen-2-5-vl-fp16",
+ "name": "qwen_2.5_vl_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Omnigen2_ComfyUI_repackaged/resolve/main/split_files/text_encoders/qwen_2.5_vl_fp16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 2,
+ "displayName": "Qwen 2.5 Vl FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/omnigen/omnigen2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_omnigen2_image_edit-1.webp"
+ },
+ {
+ "slug": "omnigen2-fp16",
+ "name": "omnigen2_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Omnigen2_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/omnigen2_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Omnigen2 FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/omnigen/omnigen2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_omnigen2_image_edit-1.webp"
+ },
+ {
+ "slug": "qwen-image-2512-fp8-e4m3fn",
+ "name": "qwen_image_2512_fp8_e4m3fn.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_2512_fp8_e4m3fn.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Qwen Image 2512 FP8 e4m3fn",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_Image_2512-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2511-lightning-4steps-v1-0-bf16",
+ "name": "Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Edit-2511-Lightning/resolve/main/Qwen-Image-Edit-2511-Lightning-4steps-V1.0-bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 2,
+ "displayName": "Qwen Image Edit 2511 Lightning 4steps V1.0 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_edit_2511-1.webp"
+ },
+ {
+ "slug": "qwen-image-layered-vae",
+ "name": "qwen_image_layered_vae.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Layered_ComfyUI/resolve/main/split_files/vae/qwen_image_layered_vae.safetensors",
+ "directory": "vae",
+ "workflowCount": 2,
+ "displayName": "Qwen Image Layered Vae",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_layered-1.webp"
+ },
+ {
+ "slug": "sd-xl-refiner-1-0",
+ "name": "sd_xl_refiner_1.0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0/resolve/main/sd_xl_refiner_1.0.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 2,
+ "displayName": "Sd Xl Refiner 1.0",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sdxl/",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sdxl_refiner_prompt_example-1.webp"
+ },
+ {
+ "slug": "wan2-1-vae-bf16",
+ "name": "Wan2_1_VAE_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan2_1_VAE_bf16.safetensors",
+ "directory": "vae",
+ "workflowCount": 2,
+ "displayName": "Wan2 1 VAE BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp"
+ },
+ {
+ "slug": "wan2-1-i2v-ati-14b-fp8-e4m3fn",
+ "name": "Wan2_1-I2V-ATI-14B_fp8_e4m3fn.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan2_1-I2V-ATI-14B_fp8_e4m3fn.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Wan2 1 I2V ATI 14B FP8 e4m3fn",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan-ati",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp"
+ },
+ {
+ "slug": "qwen-edit-2509-multiple-angles",
+ "name": "Qwen-Edit-2509-Multiple-angles.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Edit-2509-Multiple-angles.safetensors",
+ "directory": "loras",
+ "workflowCount": 2,
+ "displayName": "Qwen Edit 2509 Multiple Angles",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-1_click_multiple_character_angles-v1.0-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-lightning-8steps-v1-0-bf16",
+ "name": "Qwen-Image-Edit-2509-Lightning-8steps-V1.0-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-2509/Qwen-Image-Edit-2509-Lightning-8steps-V1.0-bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 2,
+ "displayName": "Qwen Image Edit 2509 Lightning 8steps V1.0 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-image_to_real-1.webp"
+ },
+ {
+ "slug": "byt5-small-glyphxl-fp16",
+ "name": "byt5_small_glyphxl_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/text_encoders/byt5_small_glyphxl_fp16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 2,
+ "displayName": "Byt5 Small Glyphxl FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp"
+ },
+ {
+ "slug": "hunyuanvideo15-latent-upsampler-1080p",
+ "name": "hunyuanvideo15_latent_upsampler_1080p.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/latent_upscale_models/hunyuanvideo15_latent_upsampler_1080p.safetensors",
+ "directory": "latent_upscale_models",
+ "workflowCount": 2,
+ "displayName": "Hunyuanvideo15 Latent Upsampler 1080p",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp"
+ },
+ {
+ "slug": "hunyuanvideo1-5-1080p-sr-distilled-fp16",
+ "name": "hunyuanvideo1.5_1080p_sr_distilled_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/diffusion_models/hunyuanvideo1.5_1080p_sr_distilled_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Hunyuanvideo1.5 1080p Sr Distilled FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp"
+ },
+ {
+ "slug": "hunyuanvideo15-vae-fp16",
+ "name": "hunyuanvideo15_vae_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/vae/hunyuanvideo15_vae_fp16.safetensors",
+ "directory": "vae",
+ "workflowCount": 2,
+ "displayName": "Hunyuanvideo15 Vae FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-t2v-lightx2v-4steps-lora-v1-1-high-noise",
+ "name": "wan2.2_t2v_lightx2v_4steps_lora_v1.1_high_noise.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/wan2.2_t2v_lightx2v_4steps_lora_v1.1_high_noise.safetensors",
+ "directory": "loras",
+ "workflowCount": 2,
+ "displayName": "Wan2.2 T2v Lightx2v 4steps Lora V1.1 High Noise",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_s2v-1.webp"
+ },
+ {
+ "slug": "wan21-wanmove-fp8-scaled-e4m3fn-kj",
+ "name": "Wan21-WanMove_fp8_scaled_e4m3fn_KJ.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy_fp8_scaled/resolve/main/WanMove/Wan21-WanMove_fp8_scaled_e4m3fn_KJ.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 2,
+ "displayName": "Wan21 WanMove FP8 scaled e4m3fn KJ",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wanmove_480p-1.webp"
+ },
+ {
+ "slug": "hunyuan3d-dit-v2-fp16",
+ "name": "hunyuan3d-dit-v2_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/hunyuan3D_2.0_repackaged/resolve/main/split_files/hunyuan3d-dit-v2_fp16.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Hunyuan3d Dit V2 FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/3d_hunyuan3d_image_to_model-1.webp"
+ },
+ {
+ "slug": "hunyuan3d-dit-v2-mv-fp16",
+ "name": "hunyuan3d-dit-v2-mv_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/hunyuan3D_2.0_repackaged/resolve/main/split_files/hunyuan3d-dit-v2-mv_fp16.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Hunyuan3d Dit V2 Mv FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/3d_hunyuan3d_multiview_to_model-1.webp"
+ },
+ {
+ "slug": "hunyuan3d-dit-v2-mv-turbo-fp16",
+ "name": "hunyuan3d-dit-v2-mv-turbo_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/hunyuan3D_2.0_repackaged/resolve/main/split_files/hunyuan3d-dit-v2-mv-turbo_fp16.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Hunyuan3d Dit V2 Mv Turbo FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/3d_hunyuan3d_multiview_to_model_turbo-1.webp"
+ },
+ {
+ "slug": "stable-audio-open-1-0",
+ "name": "stable-audio-open-1.0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-audio-open-1.0_repackaged/resolve/main/stable-audio-open-1.0.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Stable Audio Open 1.0",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/audio/"
+ },
+ {
+ "slug": "t5-base",
+ "name": "t5-base.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/ComfyUI-Wiki/t5-base/resolve/main/t5-base.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "T5 Base",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/audio/"
+ },
+ {
+ "slug": "v1-5-pruned-emaonly-fp16",
+ "name": "v1-5-pruned-emaonly-fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "V1 5 Pruned Emaonly FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/basic/text-to-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/default-1.webp"
+ },
+ {
+ "slug": "uso-flux1-projector-v1",
+ "name": "uso-flux1-projector-v1.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/USO_1.0_Repackaged/resolve/main/split_files/model_patches/uso-flux1-projector-v1.safetensors",
+ "directory": "model_patches",
+ "workflowCount": 1,
+ "displayName": "Uso Flux1 Projector V1",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-uso",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_dev_uso_reference_image_gen-1.webp"
+ },
+ {
+ "slug": "uso-flux1-dit-lora-v1",
+ "name": "uso-flux1-dit-lora-v1.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/USO_1.0_Repackaged/resolve/main/split_files/loras/uso-flux1-dit-lora-v1.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Uso Flux1 Dit Lora V1",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-uso",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_dev_uso_reference_image_gen-1.webp"
+ },
+ {
+ "slug": "flux1-krea-dev-fp8-scaled",
+ "name": "flux1-krea-dev_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/FLUX.1-Krea-dev_ComfyUI/resolve/main/split_files/diffusion_models/flux1-krea-dev_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux1 Krea Dev FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux1-krea-dev",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux1_krea_dev-1.webp"
+ },
+ {
+ "slug": "flux1-canny-dev",
+ "name": "flux1-canny-dev.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/split_files/diffusion_models/flux1-canny-dev.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux1 Canny Dev",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-controlnet",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_canny_model_example-1.webp"
+ },
+ {
+ "slug": "flux1-depth-dev-lora",
+ "name": "flux1-depth-dev-lora.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-dev/resolve/main/split_files/loras/flux1-depth-dev-lora.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Flux1 Depth Dev Lora",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-controlnet",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_depth_lora_example-1.webp"
+ },
+ {
+ "slug": "flux1-dev-kontext-fp8-scaled",
+ "name": "flux1-dev-kontext_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-kontext-dev_ComfyUI/resolve/main/split_files/diffusion_models/flux1-dev-kontext_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux1 Dev Kontext FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_kontext_dev_basic-1.webp"
+ },
+ {
+ "slug": "flux1-redux-dev",
+ "name": "flux1-redux-dev.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Flux1-Redux-Dev/resolve/main/flux1-redux-dev.safetensors",
+ "directory": "style_models",
+ "workflowCount": 1,
+ "displayName": "Flux1 Redux Dev",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-controlnet",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_redux_model_example-1.webp"
+ },
+ {
+ "slug": "flux1-schnell-fp8",
+ "name": "flux1-schnell-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Flux1 Schnell FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-text-to-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_schnell-1.webp"
+ },
+ {
+ "slug": "flux1-schnell",
+ "name": "flux1-schnell.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux1 Schnell",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-1-text-to-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/flux_schnell_full_text_to_image-1.webp"
+ },
+ {
+ "slug": "hidream-e1-1-bf16",
+ "name": "hidream_e1_1_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_e1_1_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hidream E1 1 BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-e1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_1-1.webp"
+ },
+ {
+ "slug": "hidream-e1-full-bf16",
+ "name": "hidream_e1_full_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_e1_full_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hidream E1 Full BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-e1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_e1_full-1.webp"
+ },
+ {
+ "slug": "hidream-i1-dev-fp8",
+ "name": "hidream_i1_dev_fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_i1_dev_fp8.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hidream I1 Dev FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_i1_dev-1.webp"
+ },
+ {
+ "slug": "hidream-i1-fast-fp8",
+ "name": "hidream_i1_fast_fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_i1_fast_fp8.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hidream I1 Fast FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_i1_fast-1.webp"
+ },
+ {
+ "slug": "hidream-i1-full-fp8",
+ "name": "hidream_i1_full_fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/diffusion_models/hidream_i1_full_fp8.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hidream I1 Full FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/hidream/hidream-i1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hidream_i1_full-1.webp"
+ },
+ {
+ "slug": "llava-llama3-fp8-scaled",
+ "name": "llava_llama3_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/text_encoders/llava_llama3_fp8_scaled.safetensors?download=true",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "Llava Llama3 FP8 scaled",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hunyuan_video_text_to_video-1.webp"
+ },
+ {
+ "slug": "hunyuan-video-t2v-720p-bf16",
+ "name": "hunyuan_video_t2v_720p_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_repackaged/resolve/main/split_files/diffusion_models/hunyuan_video_t2v_720p_bf16.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hunyuan Video T2v 720p BF16",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/hunyuan_video/",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/hunyuan_video_text_to_video-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2511-systms-infl8",
+ "name": "Qwen_Image_Edit_2511-SYSTMS_INFL8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/systms/SYSTMS-INFL8-LoRA-Qwen-Image-Edit-2511/resolve/main/SYSTMS_INFL8_LoRA_Qwen_Image_Edit_2511.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit 2511 SYSTMS INFL8",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image-qwen_image_edit_2511-lora-inflation-1.webp"
+ },
+ {
+ "slug": "chroma-radiance-x0",
+ "name": "chroma-radiance-x0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Chroma1-Radiance_Repackaged/resolve/main/split_files/diffusion_models/chroma-radiance-x0.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Chroma Radiance X0",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chroma1_radiance_text_to_image-1.webp"
+ },
+ {
+ "slug": "chroma1-hd-fp8mixed",
+ "name": "Chroma1-HD-fp8mixed.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Chroma1-HD_repackaged/resolve/main/split_files/diffusion_models/Chroma1-HD-fp8mixed.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Chroma1 HD fp8mixed",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chroma_text_to_image-1.webp"
+ },
+ {
+ "slug": "chronoedit-distill-lora",
+ "name": "chronoedit_distill_lora.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/chronoedit_distill_lora.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Chronoedit Distill Lora",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chrono_edit_14B-1.webp"
+ },
+ {
+ "slug": "chrono-edit-14b-fp16",
+ "name": "chrono_edit_14B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/chrono_edit_14B_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Chrono Edit 14B FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_chrono_edit_14B-1.webp"
+ },
+ {
+ "slug": "flux-1-fill-dev-onereward-transformer-fp8",
+ "name": "flux.1-fill-dev-OneReward-transformer_fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/OneReward_repackaged/resolve/main/split_files/diffusion_models/flux.1-fill-dev-OneReward-transformer_fp8.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux.1 Fill Dev OneReward Transformer FP8",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux.1_fill_dev_OneReward-1.webp"
+ },
+ {
+ "slug": "removal-timestep-alpha-2-1740",
+ "name": "removal_timestep_alpha-2-1740.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lrzjason/ObjectRemovalFluxFill/resolve/main/removal_timestep_alpha-2-1740.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Removal Timestep Alpha 2 1740",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux.1_fill_dev_OneReward-1.webp"
+ },
+ {
+ "slug": "flux-2-turbo-lora-comfyui",
+ "name": "Flux_2-Turbo-LoRA_comfyui.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/ByteZSzn/Flux.2-Turbo-ComfyUI/resolve/main/Flux_2-Turbo-LoRA_comfyui.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Flux 2 Turbo LoRA Comfyui",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2-1.webp"
+ },
+ {
+ "slug": "mistral-3-small-flux2-fp8",
+ "name": "mistral_3_small_flux2_fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/text_encoders/mistral_3_small_flux2_fp8.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "Mistral 3 Small Flux2 FP8",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_fp8-1.webp",
+ "canonicalSlug": "mistral-3-small-flux2-bf16"
+ },
+ {
+ "slug": "flux2turbocomfyv2",
+ "name": "Flux2TurboComfyv2.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/loras/Flux2TurboComfyv2.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Flux2TurboComfyv2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_fp8-1.webp"
+ },
+ {
+ "slug": "flux-2-klein-base-4b-fp8",
+ "name": "flux-2-klein-base-4b-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/black-forest-labs/FLUX.2-klein-base-4b-fp8/resolve/main/flux-2-klein-base-4b-fp8.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux 2 Klein Base 4b FP8",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_4b_base-1.webp"
+ },
+ {
+ "slug": "flux-2-klein-4b-fp8",
+ "name": "flux-2-klein-4b-fp8.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/black-forest-labs/FLUX.2-klein-4b-fp8/resolve/main/flux-2-klein-4b-fp8.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux 2 Klein 4b FP8",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_image_edit_4b_distilled-1.webp"
+ },
+ {
+ "slug": "flux-2-klein-base-4b",
+ "name": "flux-2-klein-base-4b.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-klein/resolve/main/split_files/diffusion_models/flux-2-klein-base-4b.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux 2 Klein Base 4b",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_text_to_image-1.webp"
+ },
+ {
+ "slug": "flux-2-klein-4b",
+ "name": "flux-2-klein-4b.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/flux2-klein/resolve/main/split_files/diffusion_models/flux-2-klein-4b.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Flux 2 Klein 4b",
+ "docsUrl": "https://docs.comfy.org/tutorials/flux/flux-2-klein",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_flux2_klein_text_to_image-1.webp"
+ },
+ {
+ "slug": "netayumev35-pretrained-all-in-one",
+ "name": "NetaYumev35_pretrained_all_in_one.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/duongve/NetaYume-Lumina-Image-2.0/resolve/main/NetaYumev35_pretrained_all_in_one.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "NetaYumev35 Pretrained All In One",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/newbie-image/newbie-image-exp-0-1",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_netayume_lumina_t2i-1.webp"
+ },
+ {
+ "slug": "newbie-image-exp0-1-bf16",
+ "name": "NewBie-Image-Exp0.1-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/NewBie-image-Exp0.1_repackaged/resolve/main/split_files/diffusion_models/NewBie-Image-Exp0.1-bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "NewBie Image Exp0.1 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_newbieimage_exp0_1-t2i-1.webp"
+ },
+ {
+ "slug": "gemma-3-4b-it-bf16",
+ "name": "gemma_3_4b_it_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/NewBie-image-Exp0.1_repackaged/resolve/main/split_files/text_encoders/gemma_3_4b_it_bf16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "Gemma 3 4b It BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_newbieimage_exp0_1-t2i-1.webp"
+ },
+ {
+ "slug": "jina-clip-v2-bf16",
+ "name": "jina_clip_v2_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/NewBie-image-Exp0.1_repackaged/resolve/main/split_files/text_encoders/jina_clip_v2_bf16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "Jina Clip V2 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_newbieimage_exp0_1-t2i-1.webp"
+ },
+ {
+ "slug": "ovis-image-bf16",
+ "name": "ovis_image_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Ovis-Image/resolve/main/split_files/diffusion_models/ovis_image_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Ovis Image BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_ovis_text_to_image-1.webp"
+ },
+ {
+ "slug": "ovis-2-5",
+ "name": "ovis_2.5.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Ovis-Image/resolve/main/split_files/text_encoders/ovis_2.5.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "Ovis 2.5",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_ovis_text_to_image-1.webp"
+ },
+ {
+ "slug": "qwen-image-lightning-8steps-v1-0",
+ "name": "Qwen-Image-Lightning-8steps-V1.0.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Lightning-8steps-V1.0.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Lightning 8steps V1.0",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image-1.webp"
+ },
+ {
+ "slug": "wuli-qwen-image-2512-turbo-lora-2steps-v1-0-bf16",
+ "name": "Wuli-Qwen-Image-2512-Turbo-LoRA-2steps-V1.0-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Wuli-art/Qwen-Image-2512-Turbo-LoRA-2-Steps/resolve/main/Wuli-Qwen-Image-2512-Turbo-LoRA-2steps-V1.0-bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Wuli Qwen Image 2512 Turbo LoRA 2steps V1.0 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_2512_with_2stpes_lora-1.webp"
+ },
+ {
+ "slug": "qwen-image-canny-diffsynth-controlnet",
+ "name": "qwen_image_canny_diffsynth_controlnet.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-DiffSynth-ControlNets/resolve/main/split_files/model_patches/qwen_image_canny_diffsynth_controlnet.safetensors",
+ "directory": "model_patches",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Canny Diffsynth Controlnet",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_controlnet_patch-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-fp8-e4m3fn",
+ "name": "qwen_image_edit_fp8_e4m3fn.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_edit_fp8_e4m3fn.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit FP8 e4m3fn",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_edit-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-lightning-4steps-v1-0-bf16",
+ "name": "Qwen-Image-Edit-Lightning-4steps-V1.0-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/lightx2v/Qwen-Image-Lightning/resolve/main/Qwen-Image-Edit-Lightning-4steps-V1.0-bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit Lightning 4steps V1.0 BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_edit-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-relight",
+ "name": "Qwen-Image-Edit-2509-Relight.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Image-Edit-2509-Relight.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit 2509 Relight",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_edit_2509_relight-1.webp"
+ },
+ {
+ "slug": "qwen-image-instantx-controlnet-union",
+ "name": "Qwen-Image-InstantX-ControlNet-Union.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets/resolve/main/split_files/controlnet/Qwen-Image-InstantX-ControlNet-Union.safetensors",
+ "directory": "controlnet",
+ "workflowCount": 1,
+ "displayName": "Qwen Image InstantX ControlNet Union",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_instantx_controlnet-1.webp"
+ },
+ {
+ "slug": "qwen-image-instantx-controlnet-inpainting",
+ "name": "Qwen-Image-InstantX-ControlNet-Inpainting.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-InstantX-ControlNets/resolve/main/split_files/controlnet/Qwen-Image-InstantX-ControlNet-Inpainting.safetensors",
+ "directory": "controlnet",
+ "workflowCount": 1,
+ "displayName": "Qwen Image InstantX ControlNet Inpainting",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_instantx_inpainting_controlnet-1.webp"
+ },
+ {
+ "slug": "qwen-image-layered-bf16",
+ "name": "qwen_image_layered_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Layered_ComfyUI/resolve/main/split_files/diffusion_models/qwen_image_layered_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Layered BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_layered-1.webp"
+ },
+ {
+ "slug": "qwen-image-layered-control-bf16",
+ "name": "qwen_image_layered_control_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/DiffSynth-Studio/Qwen-Image-Layered-Control/resolve/main/qwen_image_layered_control_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Layered Control BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_layered_control-1.webp"
+ },
+ {
+ "slug": "qwen-image-union-diffsynth-lora",
+ "name": "qwen_image_union_diffsynth_lora.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-DiffSynth-ControlNets/resolve/main/split_files/loras/qwen_image_union_diffsynth_lora.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Union Diffsynth Lora",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/qwen/qwen-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_qwen_image_union_control_lora-1.webp"
+ },
+ {
+ "slug": "wan2-1-i2v-480p-14b-fp16",
+ "name": "wan2.1_i2v_480p_14B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_i2v_480p_14B_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 I2v 480p 14B FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan-video",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_to_video_wan-1.webp"
+ },
+ {
+ "slug": "z-image-bf16",
+ "name": "z_image_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/z_image/resolve/main/split_files/diffusion_models/z_image_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Z Image BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/image/z-image/z-image",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_z_image-1.webp"
+ },
+ {
+ "slug": "pixel-art-style-z-image-turbo",
+ "name": "pixel_art_style_z_image_turbo.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/pixel_art_style_z_image_turbo.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Pixel Art Style Z Image Turbo",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_z_image_turbo-1.webp"
+ },
+ {
+ "slug": "z-image-turbo-fun-controlnet-union",
+ "name": "Z-Image-Turbo-Fun-Controlnet-Union.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/alibaba-pai/Z-Image-Turbo-Fun-Controlnet-Union/resolve/main/Z-Image-Turbo-Fun-Controlnet-Union.safetensors",
+ "directory": "model_patches",
+ "workflowCount": 1,
+ "displayName": "Z Image Turbo Fun Controlnet Union",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/image_z_image_turbo_fun_union_controlnet-1.webp"
+ },
+ {
+ "slug": "ltx-video-2b-v0-9-5",
+ "name": "ltx-video-2b-v0.9.5.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-Video/resolve/main/ltx-video-2b-v0.9.5.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Ltx Video 2b V0.9.5",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/ltxv",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/ltxv_image_to_video-1.webp"
+ },
+ {
+ "slug": "ltx-video-2b-v0-9",
+ "name": "ltx-video-2b-v0.9.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-Video/resolve/main/ltx-video-2b-v0.9.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Ltx Video 2b V0.9",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/ltxv",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/ltxv_text_to_video-1.webp"
+ },
+ {
+ "slug": "sd3-5-large-controlnet-blur",
+ "name": "sd3.5_large_controlnet_blur.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-3.5-controlnets_ComfyUI_repackaged/resolve/main/split_files/controlnet/sd3.5_large_controlnet_blur.safetensors",
+ "directory": "controlnet",
+ "workflowCount": 1,
+ "displayName": "Sd3.5 Large Controlnet Blur",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sd3.5_large_blur-1.webp"
+ },
+ {
+ "slug": "sd3-5-large-controlnet-canny",
+ "name": "sd3.5_large_controlnet_canny.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-3.5-controlnets_ComfyUI_repackaged/resolve/main/split_files/controlnet/sd3.5_large_controlnet_canny.safetensors",
+ "directory": "controlnet",
+ "workflowCount": 1,
+ "displayName": "Sd3.5 Large Controlnet Canny",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sd3.5_large_canny_controlnet_example-1.webp"
+ },
+ {
+ "slug": "sd3-5-large-controlnet-depth",
+ "name": "sd3.5_large_controlnet_depth.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/stable-diffusion-3.5-controlnets_ComfyUI_repackaged/resolve/main/split_files/controlnet/sd3.5_large_controlnet_depth.safetensors",
+ "directory": "controlnet",
+ "workflowCount": 1,
+ "displayName": "Sd3.5 Large Controlnet Depth",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sd3/#sd35-controlnets",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sd3.5_large_depth-1.webp"
+ },
+ {
+ "slug": "clip-vision-g",
+ "name": "clip_vision_g.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/comfyanonymous/clip_vision_g/resolve/main/clip_vision_g.safetensors?download=true",
+ "directory": "clip_vision",
+ "workflowCount": 1,
+ "displayName": "Clip Vision G",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sdxl/#revision",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sdxl_revision_text_prompts-1.webp"
+ },
+ {
+ "slug": "sd-xl-turbo-1-0-fp16",
+ "name": "sd_xl_turbo_1.0_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/stabilityai/sdxl-turbo/resolve/main/sd_xl_turbo_1.0_fp16.safetensors",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Sd Xl Turbo 1.0 FP16",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/sdturbo/",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/sdxlturbo_example-1.webp"
+ },
+ {
+ "slug": "wan21-t2v-14b-lightx2v-cfg-step-distill-lora-rank32",
+ "name": "Wan21_T2V_14B_lightx2v_cfg_step_distill_lora_rank32.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan21_T2V_14B_lightx2v_cfg_step_distill_lora_rank32.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Wan21 T2V 14B Lightx2v Cfg Step Distill Lora Rank32",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp"
+ },
+ {
+ "slug": "umt5-xxl-enc-bf16",
+ "name": "umt5-xxl-enc-bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/umt5-xxl-enc-bf16.safetensors",
+ "directory": "text_encoders",
+ "workflowCount": 1,
+ "displayName": "Umt5 Xxl Enc BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp"
+ },
+ {
+ "slug": "clip-vit-h-14-laion2b-s32b-b79k",
+ "name": "CLIP-ViT-H-14-laion2B-s32B-b79K.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/CLIP-ViT-H-14-laion2B-s32B-b79K_repackaged/resolve/main/split_files/clip_vision/CLIP-ViT-H-14-laion2B-s32B-b79K.safetensors",
+ "directory": "clip_vision",
+ "workflowCount": 1,
+ "displayName": "CLIP ViT H 14 Laion2B S32B B79K",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/template-Animation_Trajectory_Control_Wan_ATI-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-anything2realalpha",
+ "name": "Qwen-Image-Edit-2509-Anything2RealAlpha.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Image-Edit-2509-Anything2RealAlpha.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit 2509 Anything2RealAlpha",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-image_to_real-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-light-migration",
+ "name": "Qwen-Image-Edit-2509-Light-Migration.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Image-Edit-2509-Light-Migration.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit 2509 Light Migration",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-portrait_light_migration-1.webp"
+ },
+ {
+ "slug": "qwen-image-edit-2509-fusion",
+ "name": "Qwen-Image-Edit-2509-Fusion.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Qwen-Image-Edit_ComfyUI/resolve/main/split_files/loras/Qwen-Image-Edit-2509-Fusion.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Qwen Image Edit 2509 Fusion",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/templates-qwen_image_edit-crop_and_stitch-fusion-1.webp"
+ },
+ {
+ "slug": "wan2-1-t2v-1-3b-fp16",
+ "name": "wan2.1_t2v_1.3B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_t2v_1.3B_fp16.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 T2v 1.3B FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan-video",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/text_to_video_wan-1.webp"
+ },
+ {
+ "slug": "svd-xt",
+ "name": "svd_xt.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/stabilityai/stable-video-diffusion-img2vid-xt/resolve/main/svd_xt.safetensors?download=true",
+ "directory": "checkpoints",
+ "workflowCount": 1,
+ "displayName": "Svd Xt",
+ "docsUrl": "https://comfyanonymous.github.io/ComfyUI_examples/video/#image-to-video",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/txt_to_image_to_video-1.webp"
+ },
+ {
+ "slug": "realesrgan-x4plus",
+ "name": "RealESRGAN_x4plus.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Real-ESRGAN_repackaged/resolve/main/RealESRGAN_x4plus.safetensors",
+ "directory": "upscale_models",
+ "workflowCount": 1,
+ "displayName": "RealESRGAN X4plus",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/ultility-gan_upscaler-1.webp"
+ },
+ {
+ "slug": "humo-17b-fp8-e4m3fn",
+ "name": "humo_17B_fp8_e4m3fn.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HuMo_ComfyUI/resolve/main/split_files/diffusion_models/humo_17B_fp8_e4m3fn.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Humo 17B FP8 e4m3fn",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_humo-1.webp"
+ },
+ {
+ "slug": "whisper-large-v3-fp16",
+ "name": "whisper_large_v3_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HuMo_ComfyUI/resolve/main/split_files/audio_encoders/whisper_large_v3_fp16.safetensors",
+ "directory": "audio_encoders",
+ "workflowCount": 1,
+ "displayName": "Whisper Large V3 FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_humo-1.webp"
+ },
+ {
+ "slug": "hunyuanvideo1-5-720p-i2v-fp16",
+ "name": "hunyuanvideo1.5_720p_i2v_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/diffusion_models/hunyuanvideo1.5_720p_i2v_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hunyuanvideo1.5 720p I2v FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_i2v-1.webp"
+ },
+ {
+ "slug": "hunyuanvideo1-5-720p-t2v-fp16",
+ "name": "hunyuanvideo1.5_720p_t2v_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/HunyuanVideo_1.5_repackaged/resolve/main/split_files/diffusion_models/hunyuanvideo1.5_720p_t2v_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Hunyuanvideo1.5 720p T2v FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_hunyuan_video_1.5_720p_t2v-1.webp"
+ },
+ {
+ "slug": "kandinsky5lite-i2v-5s",
+ "name": "kandinsky5lite_i2v_5s.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/kandinskylab/Kandinsky-5.0-I2V-Lite-5s/resolve/main/model/kandinsky5lite_i2v_5s.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Kandinsky5lite I2v 5s",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_kandinsky5_i2v-1.webp"
+ },
+ {
+ "slug": "kandinsky5lite-t2v-sft-5s",
+ "name": "kandinsky5lite_t2v_sft_5s.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/kandinskylab/Kandinsky-5.0-T2V-Lite-sft-5s/resolve/main/model/kandinsky5lite_t2v_sft_5s.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Kandinsky5lite T2v Sft 5s",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_kandinsky5_t2v-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-ic-lora-canny-control",
+ "name": "ltx-2-19b-ic-lora-canny-control.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Canny-Control/resolve/main/ltx-2-19b-ic-lora-canny-control.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Ltx 2 19b Ic Lora Canny Control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_canny_to_video-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-ic-lora-depth-control",
+ "name": "ltx-2-19b-ic-lora-depth-control.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Depth-Control/resolve/main/ltx-2-19b-ic-lora-depth-control.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Ltx 2 19b Ic Lora Depth Control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_depth_to_video-1.webp"
+ },
+ {
+ "slug": "ltx-2-19b-ic-lora-pose-control",
+ "name": "ltx-2-19b-ic-lora-pose-control.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Lightricks/LTX-2-19b-IC-LoRA-Pose-Control/resolve/main/ltx-2-19b-ic-lora-pose-control.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Ltx 2 19b Ic Lora Pose Control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_ltx2_pose_to_video-1.webp"
+ },
+ {
+ "slug": "wan2-1-t2v-14b-fp8-scaled",
+ "name": "wan2.1_t2v_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_t2v_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 T2v 14B FP8 scaled",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp"
+ },
+ {
+ "slug": "wan-alpha-2-1-rgba-lora",
+ "name": "wan_alpha_2.1_rgba_lora.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/loras/wan_alpha_2.1_rgba_lora.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Wan Alpha 2.1 Rgba Lora",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp"
+ },
+ {
+ "slug": "wan-alpha-2-1-vae-rgb-channel",
+ "name": "wan_alpha_2.1_vae_rgb_channel.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/vae/wan_alpha_2.1_vae_rgb_channel.safetensors",
+ "directory": "vae",
+ "workflowCount": 1,
+ "displayName": "Wan Alpha 2.1 Vae Rgb Channel",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp"
+ },
+ {
+ "slug": "wan-alpha-2-1-vae-alpha-channel",
+ "name": "wan_alpha_2.1_vae_alpha_channel.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/vae/wan_alpha_2.1_vae_alpha_channel.safetensors",
+ "directory": "vae",
+ "workflowCount": 1,
+ "displayName": "Wan Alpha 2.1 Vae Alpha Channel",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp"
+ },
+ {
+ "slug": "lightx2v-t2v-14b-cfg-step-distill-v2-lora-rank64-bf16",
+ "name": "lightx2v_T2V_14B_cfg_step_distill_v2_lora_rank64_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Lightx2v/lightx2v_T2V_14B_cfg_step_distill_v2_lora_rank64_bf16.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Lightx2v T2V 14B Cfg Step Distill V2 Lora Rank64 BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_alpha_t2v_14B-1.webp"
+ },
+ {
+ "slug": "wan2-1-fun-camera-v1-1-1-3b-bf16",
+ "name": "wan2.1_fun_camera_v1.1_1.3B_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_camera_v1.1_1.3B_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 Fun Camera V1.1 1.3B BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/fun-control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_fun_camera_v1.1_1.3B-1.webp"
+ },
+ {
+ "slug": "wan2-1-fun-camera-v1-1-14b-bf16",
+ "name": "wan2.1_fun_camera_v1.1_14B_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_camera_v1.1_14B_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 Fun Camera V1.1 14B BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/fun-control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2.1_fun_camera_v1.1_14B-1.webp"
+ },
+ {
+ "slug": "wan2-1-i2v-14b-480p-fp8-e4m3fn-scaled-kj",
+ "name": "Wan2_1-I2V-14B-480p_fp8_e4m3fn_scaled_KJ.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy_fp8_scaled/resolve/main/I2V/Wan2_1-I2V-14B-480p_fp8_e4m3fn_scaled_KJ.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2 1 I2V 14B 480p FP8 e4m3fn scaled KJ",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_1_infinitetalk-1.webp"
+ },
+ {
+ "slug": "wan2-1-infinitetalk-multi-fp16",
+ "name": "wan2.1_infiniteTalk_multi_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/model_patches/wan2.1_infiniteTalk_multi_fp16.safetensors",
+ "directory": "model_patches",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 InfiniteTalk Multi FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_1_infinitetalk-1.webp"
+ },
+ {
+ "slug": "wav2vec2-chinese-base-fp16",
+ "name": "wav2vec2-chinese-base_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/wav2vec2_safetensors/resolve/main/wav2vec2-chinese-base_fp16.safetensors",
+ "directory": "audio_encoders",
+ "workflowCount": 1,
+ "displayName": "Wav2vec2 Chinese Base FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_1_infinitetalk-1.webp"
+ },
+ {
+ "slug": "wananimate-relight-lora-fp16",
+ "name": "WanAnimate_relight_lora_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/LoRAs/Wan22_relight/WanAnimate_relight_lora_fp16.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "WanAnimate Relight Lora FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-animate",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_animate-1.webp"
+ },
+ {
+ "slug": "wan2-2-animate-14b-fp8-e4m3fn-scaled-kj",
+ "name": "Wan2_2-Animate-14B_fp8_e4m3fn_scaled_KJ.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Kijai/WanVideo_comfy_fp8_scaled/resolve/main/Wan22Animate/Wan2_2-Animate-14B_fp8_e4m3fn_scaled_KJ.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2 2 Animate 14B FP8 e4m3fn scaled KJ",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-animate",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_animate-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-camera-high-noise-14b-fp8-scaled",
+ "name": "wan2.2_fun_camera_high_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_camera_high_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Camera High Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-camera",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_camera-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-camera-low-noise-14b-fp8-scaled",
+ "name": "wan2.2_fun_camera_low_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_camera_low_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Camera Low Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-camera",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_camera-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-control-high-noise-14b-fp8-scaled",
+ "name": "wan2.2_fun_control_high_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_control_high_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Control High Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_control-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-control-low-noise-14b-fp8-scaled",
+ "name": "wan2.2_fun_control_low_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_control_low_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Control Low Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_control-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-inpaint-high-noise-14b-fp8-scaled",
+ "name": "wan2.2_fun_inpaint_high_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_inpaint_high_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Inpaint High Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_inpaint-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-inpaint-low-noise-14b-fp8-scaled",
+ "name": "wan2.2_fun_inpaint_low_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_inpaint_low_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Inpaint Low Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_fun_inpaint-1.webp"
+ },
+ {
+ "slug": "wav2vec2-large-english-fp16",
+ "name": "wav2vec2_large_english_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/audio_encoders/wav2vec2_large_english_fp16.safetensors",
+ "directory": "audio_encoders",
+ "workflowCount": 1,
+ "displayName": "Wav2vec2 Large English FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-s2v",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_s2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-s2v-14b-fp8-scaled",
+ "name": "wan2.2_s2v_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_s2v_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 S2v 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2-2-s2v",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_s2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-t2v-low-noise-14b-fp8-scaled",
+ "name": "wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_low_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 T2v Low Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_t2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-t2v-high-noise-14b-fp8-scaled",
+ "name": "wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_t2v_high_noise_14B_fp8_scaled.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 T2v High Noise 14B FP8 scaled",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_t2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-t2v-lightx2v-4steps-lora-v1-1-low-noise",
+ "name": "wan2.2_t2v_lightx2v_4steps_lora_v1.1_low_noise.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/loras/wan2.2_t2v_lightx2v_4steps_lora_v1.1_low_noise.safetensors",
+ "directory": "loras",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 T2v Lightx2v 4steps Lora V1.1 Low Noise",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan2_2",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_14B_t2v-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-control-5b-bf16",
+ "name": "wan2.2_fun_control_5B_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_control_5B_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Control 5B BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_fun_control-1.webp"
+ },
+ {
+ "slug": "wan2-2-fun-inpaint-5b-bf16",
+ "name": "wan2.2_fun_inpaint_5B_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_inpaint_5B_bf16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Fun Inpaint 5B BF16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_fun_inpaint-1.webp"
+ },
+ {
+ "slug": "wan2-2-ti2v-5b-fp16",
+ "name": "wan2.2_ti2v_5B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_ti2v_5B_fp16.safetensors",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.2 Ti2v 5B FP16",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/video_wan2_2_5B_ti2v-1.webp"
+ },
+ {
+ "slug": "wan2-1-flf2v-720p-14b-fp16",
+ "name": "wan2.1_flf2v_720p_14B_fp16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_flf2v_720p_14B_fp16.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 Flf2v 720p 14B FP16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/wan-flf",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/wan2.1_flf2v_720_f16-1.webp"
+ },
+ {
+ "slug": "wan2-1-fun-control-1-3b-bf16",
+ "name": "wan2.1_fun_control_1.3B_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_control_1.3B_bf16.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 Fun Control 1.3B BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/fun-control",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/wan2.1_fun_control-1.webp"
+ },
+ {
+ "slug": "wan2-1-fun-inp-1-3b-bf16",
+ "name": "wan2.1_fun_inp_1.3B_bf16.safetensors",
+ "huggingFaceUrl": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_inp_1.3B_bf16.safetensors?download=true",
+ "directory": "diffusion_models",
+ "workflowCount": 1,
+ "displayName": "Wan2.1 Fun Inp 1.3B BF16",
+ "docsUrl": "https://docs.comfy.org/tutorials/video/wan/fun-inp",
+ "thumbnailUrl": "https://raw.githubusercontent.com/Comfy-Org/workflow_templates/main/templates/wan2.1_fun_inp-1.webp"
+ }
+]
diff --git a/apps/website/src/config/model-metadata.ts b/apps/website/src/config/model-metadata.ts
new file mode 100644
index 0000000000..07f8e1ec15
--- /dev/null
+++ b/apps/website/src/config/model-metadata.ts
@@ -0,0 +1,208 @@
+interface ModelOverride {
+ docsUrl?: string
+ blogUrl?: string
+ featured?: boolean
+ // Slug used on comfy.org/workflows/model/{hubSlug}. Only set when the page exists.
+ hubSlug?: string
+}
+
+export const modelMetadata: Record = {
+ 'nano-banana': {
+ docsUrl:
+ 'https://docs.comfy.org/tutorials/partner-nodes/google/nano-banana-pro',
+ hubSlug: 'nano-banana',
+ featured: true
+ },
+ 'kling-ai': {
+ docsUrl:
+ 'https://docs.comfy.org/tutorials/partner-nodes/kling/kling-motion-control',
+ hubSlug: 'kling',
+ featured: true
+ },
+ 'meshy-ai': {
+ docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/meshy/meshy-6',
+ hubSlug: 'meshy',
+ featured: true
+ },
+ 'openai-dall-e': {
+ docsUrl: 'https://docs.comfy.org/tutorials/partner-nodes/openai/dall-e-3',
+ hubSlug: 'openai',
+ featured: true
+ },
+ 'ltxv-api': {
+ docsUrl: 'https://docs.comfy.org/tutorials/video/ltxv',
+ hubSlug: 'ltx-2-3',
+ featured: true
+ },
+ 'wan-api': {
+ docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
+ hubSlug: 'wan',
+ featured: true
+ },
+ 'wan-2-2': {
+ docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan2_2',
+ hubSlug: 'wan',
+ featured: true
+ },
+ 'wan-2-1': {
+ docsUrl: 'https://docs.comfy.org/tutorials/video/wan/wan-video',
+ hubSlug: 'wan',
+ featured: true
+ },
+ 'flux-1-kontext-dev': {
+ docsUrl:
+ 'https://docs.comfy.org/tutorials/partner-nodes/black-forest-labs/flux-1-kontext',
+ hubSlug: 'flux-1-kontext',
+ featured: true
+ },
+ 'flux1-dev': {
+ docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-text-to-image',
+ hubSlug: 'flux-1',
+ featured: true
+ },
+ 'flux1-schnell': {
+ hubSlug: 'flux-1',
+ featured: true
+ },
+ 'hunyuan-video': {
+ docsUrl: 'https://docs.comfy.org/tutorials/video/hunyuan/hunyuan-video',
+ hubSlug: 'hunyuan-video',
+ featured: true
+ },
+ 'hunyuan-3d': {
+ docsUrl: 'https://docs.comfy.org/tutorials/3d/hunyuan3D-2',
+ hubSlug: 'hunyuan-3d',
+ featured: true
+ },
+ vidu: {
+ hubSlug: 'vidu',
+ featured: true
+ },
+ runway: {
+ hubSlug: 'runway',
+ featured: true
+ },
+ 'stability-ai': {
+ hubSlug: 'stability',
+ featured: true
+ },
+ 'seedance-bytedance': {
+ hubSlug: 'seedance',
+ featured: true
+ },
+ 'grok-image': {
+ hubSlug: 'grok',
+ featured: false
+ },
+ 'luma-dream-machine': {
+ hubSlug: 'luma',
+ featured: false
+ },
+ moonvalley: {
+ hubSlug: 'moonvalley',
+ featured: false
+ },
+ 'magnific-ai': {
+ hubSlug: 'magnific',
+ featured: false
+ },
+ pixverse: {
+ hubSlug: 'pixverse',
+ featured: false
+ },
+ 'rodin-3d': {
+ hubSlug: 'rodin',
+ featured: false
+ },
+ recraft: {
+ hubSlug: 'recraft',
+ featured: false
+ },
+ 'bria-ai': {
+ hubSlug: 'bria',
+ featured: false
+ },
+ 'topaz-labs': {
+ hubSlug: 'topaz',
+ featured: false
+ },
+ wavespeed: {
+ hubSlug: 'wavespeed',
+ featured: false
+ },
+ ideogram: {
+ hubSlug: 'ideogram',
+ featured: false
+ },
+ 'veo-2': {
+ hubSlug: 'veo',
+ featured: false
+ },
+ 'veo-3': {
+ hubSlug: 'veo',
+ featured: false
+ },
+ 'flux-2-api': {
+ hubSlug: 'flux-2',
+ featured: false
+ },
+ 'ace-step-v1-3-5b': {
+ docsUrl: 'https://docs.comfy.org/tutorials/audio/ace-step/ace-step-v1',
+ hubSlug: 'ace-step',
+ featured: false
+ },
+ 'hidream-i1-dev-fp8': {
+ docsUrl: 'https://docs.comfy.org/tutorials/image/hidream/hidream-i1',
+ hubSlug: 'hidream',
+ featured: false
+ },
+ 'omnigen2-fp16': {
+ hubSlug: 'omnigen',
+ featured: false
+ },
+ 'sd-xl-base-1-0': {
+ hubSlug: 'sdxl',
+ featured: false
+ },
+ 'z-image-bf16': {
+ hubSlug: 'z-image',
+ featured: false
+ },
+ 'z-image-turbo-bf16': {
+ hubSlug: 'z-image',
+ featured: false
+ },
+ 'svd-xt': {
+ hubSlug: 'svd',
+ featured: false
+ },
+ 'flux1-dev-kontext-fp8-scaled': {
+ docsUrl: 'https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev',
+ hubSlug: 'flux-1-kontext',
+ featured: false
+ },
+ 'ltx-2-19b-dev-fp8': {
+ hubSlug: 'ltx-2',
+ featured: false
+ },
+ 'ltx-2-19b-distilled': {
+ hubSlug: 'ltx-2',
+ featured: false
+ },
+ 'flux1-fill-dev': {
+ hubSlug: 'flux-1',
+ featured: false
+ },
+ 'flux-2-klein-base-9b-fp8': {
+ hubSlug: 'flux-2',
+ featured: false
+ },
+ 'qwen-image-fp8-e4m3fn': {
+ hubSlug: 'qwen',
+ featured: false
+ },
+ 'qwen-image-edit-2509-fp8-e4m3fn': {
+ hubSlug: 'qwen',
+ featured: false
+ }
+}
diff --git a/apps/website/src/config/models.ts b/apps/website/src/config/models.ts
new file mode 100644
index 0000000000..c779ac1430
--- /dev/null
+++ b/apps/website/src/config/models.ts
@@ -0,0 +1,81 @@
+import generatedModels from './generated-models.json'
+import { modelMetadata } from './model-metadata'
+
+type ModelDirectory =
+ | 'diffusion_models'
+ | 'checkpoints'
+ | 'loras'
+ | 'controlnet'
+ | 'clip_vision'
+ | 'model_patches'
+ | 'vae'
+ | 'text_encoders'
+ | 'audio_encoders'
+ | 'latent_upscale_models'
+ | 'upscale_models'
+ | 'style_models'
+ | 'partner_nodes'
+
+interface Model {
+ readonly slug: string
+ readonly canonicalSlug?: string
+ readonly name: string
+ readonly displayName: string
+ readonly directory: ModelDirectory
+ readonly huggingFaceUrl: string
+ readonly thumbnailUrl?: string
+ readonly docsUrl?: string
+ readonly blogUrl?: string
+ readonly hubSlug?: string
+ readonly featured: boolean
+ readonly workflowCount: number
+}
+
+export const models: readonly Model[] = (
+ generatedModels as Array<{
+ slug: string
+ canonicalSlug?: string
+ name: string
+ displayName: string
+ directory: string
+ huggingFaceUrl: string
+ docsUrl?: string
+ thumbnailUrl?: string
+ workflowCount: number
+ }>
+).map((m) => ({
+ slug: m.slug,
+ ...(m.canonicalSlug ? { canonicalSlug: m.canonicalSlug } : {}),
+ name: m.name,
+ displayName: m.displayName,
+ directory: m.directory as ModelDirectory,
+ huggingFaceUrl: m.huggingFaceUrl,
+ ...(m.docsUrl ? { docsUrl: m.docsUrl } : {}),
+ ...(m.thumbnailUrl ? { thumbnailUrl: m.thumbnailUrl } : {}),
+ featured: false,
+ workflowCount: m.workflowCount,
+ ...modelMetadata[m.slug]
+}))
+
+const slugSet = new Set(models.map((m) => m.slug))
+if (slugSet.size !== models.length) {
+ for (const model of models) {
+ if (models.filter((m) => m.slug === model.slug).length > 1) {
+ throw new Error(`Duplicate model slug: ${model.slug}`)
+ }
+ }
+}
+for (const model of models) {
+ if (
+ model.canonicalSlug !== undefined &&
+ (!slugSet.has(model.canonicalSlug) || model.canonicalSlug === model.slug)
+ ) {
+ throw new Error(
+ `Invalid canonicalSlug "${model.canonicalSlug}" on "${model.slug}"`
+ )
+ }
+}
+
+export function getModelBySlug(slug: string): Model | undefined {
+ return models.find((m) => m.slug === slug)
+}
diff --git a/apps/website/src/config/routes.ts b/apps/website/src/config/routes.ts
index 8fa008e3b9..eb215b5c98 100644
--- a/apps/website/src/config/routes.ts
+++ b/apps/website/src/config/routes.ts
@@ -11,9 +11,11 @@ const baseRoutes = {
about: '/about',
careers: '/careers',
customers: '/customers',
+ demos: '/demos',
termsOfService: '/terms-of-service',
privacyPolicy: '/privacy-policy',
- contact: '/contact'
+ contact: '/contact',
+ models: '/p/supported-models'
} as const
type Routes = typeof baseRoutes
@@ -33,8 +35,11 @@ export const externalLinks = {
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/api-reference/cloud',
+ docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
github: 'https://github.com/Comfy-Org/ComfyUI',
+ githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
platform: 'https://platform.comfy.org',
+ platformUsage: 'https://platform.comfy.org/profile/usage',
support: 'https://support.comfy.org/hc/en-us',
workflows: 'https://comfy.org/workflows',
youtube: 'https://www.youtube.com/@ComfyOrg'
diff --git a/apps/website/src/data/ashby-roles.snapshot.json b/apps/website/src/data/ashby-roles.snapshot.json
index b738e98407..455930cd62 100644
--- a/apps/website/src/data/ashby-roles.snapshot.json
+++ b/apps/website/src/data/ashby-roles.snapshot.json
@@ -1,37 +1,30 @@
{
- "fetchedAt": "2026-05-02T20:15:18.321Z",
+ "fetchedAt": "2026-05-12T16:10:34.114Z",
"departments": [
{
"name": "DESIGN",
"key": "design",
"roles": [
{
- "id": "e915f2c78b17f93b",
+ "id": "18743530eb448c99",
"title": "Senior Product Designer",
"department": "Design",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/b2e864c6-4754-4e04-8f46-1022baa103c3"
},
{
- "id": "b9f9a23219be7cd4",
- "title": "Design Engineer",
- "department": "Design",
- "location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/abc787b9-ad85-421c-8218-debd23bea096/application"
- },
- {
- "id": "547b6ba622c800a5",
+ "id": "8718d17012f26fa2",
"title": "Senior Product Designer - Craft",
"department": "Design",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/a32c6769-b791-41f4-9225-50bbd8a1cf0f"
},
{
- "id": "7bb02634a24763bc",
+ "id": "1e181b9ed8fb2e86",
"title": "Staff Product Designer - Systems",
"department": "Design",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/0bc8356b-615e-4f40-b632-fd3b2691be34"
}
]
},
@@ -40,67 +33,67 @@
"key": "engineering",
"roles": [
{
- "id": "102d58e35a8a9817",
+ "id": "6a6d865eeb3c10a8",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
},
{
- "id": "d01d69fba7743905",
+ "id": "1b4f7f1da9616e14",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
},
{
- "id": "f36f60cfd5bb5910",
+ "id": "a6d8269c66e37c5c",
"title": "Senior/Staff Applied Machine Learning Engineer",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/5cc4d0bc-97b0-463b-8466-3ec1d07f6ac0"
},
{
- "id": "9d8ec4c65e20b19e",
+ "id": "841da783e6e41928",
"title": "Software Engineer, Frontend",
"department": "Engineering",
"location": "Remote",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/99dc26c7-51ca-43cd-a1ba-7d475a0f4a40"
},
{
- "id": "be94b193d1f4d482",
+ "id": "5d01d58b03870d7a",
"title": "Tech Lead Manager, Frontend",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/a0665088-3314-457a-aa7b-12ca5c3eb261"
},
{
- "id": "ab48f5db6bd1783c",
+ "id": "91604c4182a1bc3c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
},
{
- "id": "c5dff4ee628bdcd1",
+ "id": "a1dbc0576ab14034",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
},
{
- "id": "4302a7aaa87e16e3",
+ "id": "0b8f4fecd89c3b11",
"title": "Product Manager, ComfyUI",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/9e4b9029-c3e9-436b-82c4-a1a9f1b8c16e"
},
{
- "id": "2eb53e8943cc9396",
+ "id": "2f6bac39d723dfef",
"title": "Growth Engineer",
"department": "Engineering",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/f1fdde76-84ae-48c1-b0f9-9654dd8e7de5"
}
]
},
@@ -109,39 +102,39 @@
"key": "marketing",
"roles": [
{
- "id": "4c5d6afb78652df7",
+ "id": "23dd98cab77ff459",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
},
{
- "id": "0f5256cf302e552b",
+ "id": "a998b9fc973ff3c0",
"title": "Creative Artist",
"department": "Marketing",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
},
{
- "id": "5746486d87874937",
+ "id": "3e730938026d6e70",
"title": "Graphic Designer",
"department": "Marketing",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
},
{
- "id": "b5803a0d4785d406",
+ "id": "6f771af6858283aa",
"title": "Lifecycle Growth Marketer",
"department": "Marketing",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/be74d210-3b50-408c-9f61-8fee8833ce64"
},
{
- "id": "130d7218d7895bdb",
+ "id": "527a47e82970afc1",
"title": "Partnership & Events Marketing Manager",
"department": "Marketing",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
}
]
},
@@ -150,25 +143,18 @@
"key": "operations",
"roles": [
{
- "id": "ec68ae44dd5943c9",
- "title": "Talent Lead",
+ "id": "0c6cc3685194ab7a",
+ "title": "Head of Talent",
"department": "Operations",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/d5008532-c45d-46e6-ba2c-20489d364362"
},
{
- "id": "16f556001ce1cef4",
- "title": "BizOps Strategist",
- "department": "Operations",
- "location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/145b8558-0ab4-43e8-8fac-b59059cf2537/application"
- },
- {
- "id": "8e773a72c1b8e099",
+ "id": "82bd6ed26adab1c3",
"title": "Founding Customer Success Manager",
"department": "Operations",
"location": "San Francisco",
- "applyUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4/application"
+ "jobUrl": "https://jobs.ashbyhq.com/comfy-org/a1c5c5ed-62ac-4767-af57-a3ba4e0bf5e4"
}
]
}
diff --git a/apps/website/src/data/cloud-nodes.snapshot.json b/apps/website/src/data/cloud-nodes.snapshot.json
new file mode 100644
index 0000000000..a5a85565db
--- /dev/null
+++ b/apps/website/src/data/cloud-nodes.snapshot.json
@@ -0,0 +1,394 @@
+{
+ "fetchedAt": "2026-05-04T16:29:55.587Z",
+ "packs": [
+ {
+ "id": "comfyui-impact-pack",
+ "registryId": "comfyui-impact-pack",
+ "displayName": "ComfyUI Impact Pack",
+ "description": "Production-grade detailer, detector, and SEG (segmentation) tooling. The most-used pack for face restoration, region-based refinement, and iterative upscaling on Comfy Cloud.",
+ "repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack",
+ "publisher": {
+ "id": "drltdata",
+ "name": "Dr.Lt.Data"
+ },
+ "downloads": 2618646,
+ "githubStars": 3092,
+ "latestVersion": "8.28.3",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2026-04-19T17:08:04.993918Z",
+ "nodes": [
+ {
+ "name": "FaceDetailer",
+ "displayName": "FaceDetailer",
+ "category": "ImpactPack/Detailer",
+ "description": "Detect and refine faces with iterative passes."
+ },
+ {
+ "name": "DetailerForEach",
+ "displayName": "DetailerForEach",
+ "category": "ImpactPack/Detailer",
+ "description": "Run iterative detail refinement over detected SEG regions."
+ },
+ {
+ "name": "UltralyticsDetectorProvider",
+ "displayName": "UltralyticsDetectorProvider",
+ "category": "ImpactPack/Detector",
+ "description": "Provide detector models powered by Ultralytics YOLO."
+ },
+ {
+ "name": "SAMLoader",
+ "displayName": "SAMLoader",
+ "category": "ImpactPack/Detector",
+ "description": "Load Segment Anything models for high-fidelity masking."
+ },
+ {
+ "name": "MaskToSEGS",
+ "displayName": "MaskToSEGS",
+ "category": "ImpactPack/Operation",
+ "description": "Convert binary masks into SEGS regions for the detailer pipeline."
+ }
+ ]
+ },
+ {
+ "id": "ComfyUI-Crystools",
+ "registryId": "ComfyUI-Crystools",
+ "displayName": "ComfyUI-Crystools",
+ "description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow. The most-installed quality-of-life pack on the registry.",
+ "iconUrl": "https://raw.githubusercontent.com/crystian/ComfyUI-Crystools/main/docs/screwdriver.png",
+ "repoUrl": "https://github.com/crystian/ComfyUI-Crystools",
+ "publisher": {
+ "id": "crystian",
+ "name": "Crystian"
+ },
+ "downloads": 1671447,
+ "githubStars": 1855,
+ "latestVersion": "1.27.4",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2025-10-26T19:11:09.943366Z",
+ "supportedOs": ["OS Independent"],
+ "supportedAccelerators": ["GPU :: NVIDIA CUDA"],
+ "nodes": [
+ {
+ "name": "CCrystools_Show_Resources",
+ "displayName": "CCrystools_Show_Resources",
+ "category": "crystools/show",
+ "description": "Display GPU, RAM and disk usage live in the workflow."
+ },
+ {
+ "name": "CCrystools_Show_Image",
+ "displayName": "CCrystools_Show_Image",
+ "category": "crystools/show",
+ "description": "Inspect images at full resolution with metadata overlays."
+ },
+ {
+ "name": "CCrystools_Json",
+ "displayName": "CCrystools_Json",
+ "category": "crystools/json",
+ "description": "Compose and parse JSON inline for advanced workflows."
+ },
+ {
+ "name": "CCrystools_Pipe_To_Any",
+ "displayName": "CCrystools_Pipe_To_Any",
+ "category": "crystools/pipe",
+ "description": "Convert a pipe bus into individual outputs."
+ },
+ {
+ "name": "CCrystools_Save_Metadata",
+ "displayName": "CCrystools_Save_Metadata",
+ "category": "crystools/save",
+ "description": "Save images with workflow metadata embedded."
+ }
+ ]
+ },
+ {
+ "id": "rgthree-comfy",
+ "registryId": "rgthree-comfy",
+ "displayName": "rgthree-comfy",
+ "description": "Quality-of-life nodes that make complex workflows readable: Power Lora Loader, group bypassers, smarter reroutes, and inline debug widgets.",
+ "iconUrl": "https://comfy.rgthree.com/media/rgthree.svg",
+ "repoUrl": "https://github.com/rgthree/rgthree-comfy",
+ "publisher": {
+ "id": "rgthree"
+ },
+ "downloads": 3025389,
+ "githubStars": 3028,
+ "latestVersion": "1.0.2604070017",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2026-04-07T04:19:24.689627Z",
+ "nodes": [
+ {
+ "name": "Power Lora Loader (rgthree)",
+ "displayName": "Power Lora Loader (rgthree)",
+ "category": "rgthree",
+ "description": "Stack multiple LoRAs in a single, foldable widget."
+ },
+ {
+ "name": "Fast Groups Bypasser (rgthree)",
+ "displayName": "Fast Groups Bypasser (rgthree)",
+ "category": "rgthree",
+ "description": "Toggle whole groups on or off without rewiring."
+ },
+ {
+ "name": "Seed (rgthree)",
+ "displayName": "Seed (rgthree)",
+ "category": "rgthree",
+ "description": "A predictable seed control with quick reset."
+ },
+ {
+ "name": "Reroute (rgthree)",
+ "displayName": "Reroute (rgthree)",
+ "category": "rgthree",
+ "description": "A clean reroute alternative with persistent labels."
+ },
+ {
+ "name": "Display Any (rgthree)",
+ "displayName": "Display Any (rgthree)",
+ "category": "rgthree",
+ "description": "Inspect any value with a compact debug widget."
+ }
+ ]
+ },
+ {
+ "id": "comfyui-kjnodes",
+ "registryId": "comfyui-kjnodes",
+ "displayName": "ComfyUI-KJNodes",
+ "description": "Daily-driver utilities for image, latent and string handling: color matching, batch counters, resize helpers, and prompt presets.",
+ "iconUrl": "https://avatars.githubusercontent.com/u/40791699",
+ "repoUrl": "https://github.com/kijai/ComfyUI-KJNodes",
+ "publisher": {
+ "id": "kijai",
+ "name": "Kijai"
+ },
+ "downloads": 3319866,
+ "githubStars": 2544,
+ "latestVersion": "1.3.9",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2026-04-24T09:32:28.326616Z",
+ "nodes": [
+ {
+ "name": "ColorMatch",
+ "displayName": "ColorMatch",
+ "category": "KJNodes/image",
+ "description": "Match the colors of one image to another using statistics."
+ },
+ {
+ "name": "ImageResizeKJ",
+ "displayName": "ImageResizeKJ",
+ "category": "KJNodes/image",
+ "description": "Resize images with intuitive size and divisibility controls."
+ },
+ {
+ "name": "StringConstantMultiline",
+ "displayName": "StringConstantMultiline",
+ "category": "KJNodes/string",
+ "description": "A multi-line string constant suitable for prompts."
+ },
+ {
+ "name": "EmptyLatentImagePresets",
+ "displayName": "EmptyLatentImagePresets",
+ "category": "KJNodes/latent",
+ "description": "Quickly create empty latents at common resolutions."
+ },
+ {
+ "name": "GetImageSizeAndCount",
+ "displayName": "GetImageSizeAndCount",
+ "category": "KJNodes/image",
+ "description": "Read width, height and batch size from an image input."
+ }
+ ]
+ },
+ {
+ "id": "comfyui-easy-use",
+ "registryId": "comfyui-easy-use",
+ "displayName": "ComfyUI-Easy-Use",
+ "description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins — full loader, pre-sampling, easy KSampler, and XY plotting.",
+ "iconUrl": "https://mintlify.s3.us-west-1.amazonaws.com/yolain/images/logo.svg",
+ "repoUrl": "https://github.com/yolain/ComfyUI-Easy-Use",
+ "publisher": {
+ "id": "yolain",
+ "name": "yolain"
+ },
+ "downloads": 2767609,
+ "githubStars": 2500,
+ "latestVersion": "1.3.6",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2026-01-23T06:19:17.505188Z",
+ "nodes": [
+ {
+ "name": "easy fullLoader",
+ "displayName": "easy fullLoader",
+ "category": "EasyUse/Loaders",
+ "description": "Combined checkpoint, VAE and CLIP loader with sensible defaults."
+ },
+ {
+ "name": "easy preSampling",
+ "displayName": "easy preSampling",
+ "category": "EasyUse/PreSampling",
+ "description": "A unified pre-sampling node bundling common settings."
+ },
+ {
+ "name": "easy kSampler",
+ "displayName": "easy kSampler",
+ "category": "EasyUse/KSampler",
+ "description": "A simplified KSampler with extra quality-of-life options."
+ },
+ {
+ "name": "easy showAnything",
+ "displayName": "easy showAnything",
+ "category": "EasyUse/Util",
+ "description": "Display any value inline for debugging."
+ },
+ {
+ "name": "easy XYPlot",
+ "displayName": "easy XYPlot",
+ "category": "EasyUse/XYPlot",
+ "description": "Compose XY plots over arbitrary parameters."
+ }
+ ]
+ },
+ {
+ "id": "comfyui-advanced-controlnet",
+ "registryId": "comfyui-advanced-controlnet",
+ "displayName": "ComfyUI-Advanced-ControlNet",
+ "description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling — essential for animation and batched-latent workflows.",
+ "repoUrl": "https://github.com/Kosinkadink/ComfyUI-Advanced-ControlNet",
+ "publisher": {
+ "id": "kosinkadink",
+ "name": "Kosinkadink"
+ },
+ "downloads": 590539,
+ "githubStars": 967,
+ "latestVersion": "1.5.7",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2026-03-30T01:40:06.836236Z",
+ "nodes": [
+ {
+ "name": "Apply Advanced ControlNet",
+ "displayName": "Apply Advanced ControlNet",
+ "category": "Adv-ControlNet/conditioning",
+ "description": "Apply ControlNet with timestep keyframes and per-frame masks."
+ },
+ {
+ "name": "ControlNetLoaderAdvanced",
+ "displayName": "ControlNetLoaderAdvanced",
+ "category": "Adv-ControlNet/loaders",
+ "description": "Load ControlNet models with the advanced wrapper."
+ },
+ {
+ "name": "Latent Keyframe Group",
+ "displayName": "Latent Keyframe Group",
+ "category": "Adv-ControlNet/keyframes",
+ "description": "Schedule ControlNet strength over a batch of latents."
+ },
+ {
+ "name": "Timestep Keyframe",
+ "displayName": "Timestep Keyframe",
+ "category": "Adv-ControlNet/keyframes",
+ "description": "Set ControlNet strength at a specific timestep."
+ },
+ {
+ "name": "Scaled Soft Mask",
+ "displayName": "Scaled Soft Mask",
+ "category": "Adv-ControlNet/masks",
+ "description": "Apply a soft attention mask to ControlNet conditioning."
+ }
+ ]
+ },
+ {
+ "id": "was-node-suite-comfyui",
+ "registryId": "was-node-suite-comfyui",
+ "displayName": "WAS Node Suite",
+ "description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O — the original \"kitchen sink\" pack still relied on by thousands of workflows.",
+ "repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui",
+ "publisher": {
+ "id": "was",
+ "name": "WAS"
+ },
+ "downloads": 981051,
+ "githubStars": 1777,
+ "latestVersion": "1.0.1",
+ "license": "See repository LICENSE",
+ "lastUpdated": "2024-08-01T05:28:23.655235Z",
+ "nodes": [
+ {
+ "name": "Image Filter Adjustments",
+ "displayName": "Image Filter Adjustments",
+ "category": "WAS Suite/Image/Adjustment",
+ "description": "Adjust brightness, contrast, saturation and more."
+ },
+ {
+ "name": "Image Blending Mode",
+ "displayName": "Image Blending Mode",
+ "category": "WAS Suite/Image/Compositing",
+ "description": "Composite two images with Photoshop-style blend modes."
+ },
+ {
+ "name": "Text String",
+ "displayName": "Text String",
+ "category": "WAS Suite/Text",
+ "description": "A reusable text constant suitable for prompts."
+ },
+ {
+ "name": "Number to Float",
+ "displayName": "Number to Float",
+ "category": "WAS Suite/Number",
+ "description": "Cast integer or string values to a float."
+ },
+ {
+ "name": "Image Save",
+ "displayName": "Image Save",
+ "category": "WAS Suite/IO",
+ "description": "Save an image to disk with rich filename templating."
+ }
+ ]
+ },
+ {
+ "id": "comfyui_ipadapter_plus",
+ "registryId": "comfyui_ipadapter_plus",
+ "displayName": "ComfyUI_IPAdapter_plus",
+ "description": "Reference-image conditioning with IPAdapter — style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.",
+ "repoUrl": "https://github.com/cubiq/ComfyUI_IPAdapter_plus",
+ "publisher": {
+ "id": "matteo",
+ "name": "Matteo"
+ },
+ "downloads": 1208394,
+ "githubStars": 5938,
+ "latestVersion": "2.0.0",
+ "license": "GPL-3.0 license",
+ "lastUpdated": "2024-06-05T06:57:13.485481Z",
+ "nodes": [
+ {
+ "name": "IPAdapterUnifiedLoader",
+ "displayName": "IPAdapterUnifiedLoader",
+ "category": "ipadapter",
+ "description": "Load IPAdapter, image encoder and CLIP vision in one node."
+ },
+ {
+ "name": "IPAdapterFaceID",
+ "displayName": "IPAdapterFaceID",
+ "category": "ipadapter/faceid",
+ "description": "Apply Face ID embeddings for high-fidelity portrait reference."
+ },
+ {
+ "name": "IPAdapterStyleComposition",
+ "displayName": "IPAdapterStyleComposition",
+ "category": "ipadapter",
+ "description": "Reference an image for style without copying its content."
+ },
+ {
+ "name": "IPAdapterAdvanced",
+ "displayName": "IPAdapterAdvanced",
+ "category": "ipadapter",
+ "description": "Full-control IPAdapter with masking, weights, and noise injection."
+ },
+ {
+ "name": "IPAdapterEncoder",
+ "displayName": "IPAdapterEncoder",
+ "category": "ipadapter/embeds",
+ "description": "Encode reference images into IPAdapter embeddings for reuse."
+ }
+ ]
+ }
+ ]
+}
diff --git a/apps/website/src/data/cloudNodes.ts b/apps/website/src/data/cloudNodes.ts
new file mode 100644
index 0000000000..fb25603564
--- /dev/null
+++ b/apps/website/src/data/cloudNodes.ts
@@ -0,0 +1,52 @@
+export interface PackNode {
+ name: string
+ displayName: string
+ category: string
+ description?: string
+ deprecated?: boolean
+ experimental?: boolean
+}
+
+export interface Pack {
+ id: string
+ registryId?: string
+ displayName: string
+ description?: string
+ bannerUrl?: string
+ iconUrl?: string
+ repoUrl?: string
+ publisher?: {
+ id: string
+ name?: string
+ }
+ downloads?: number
+ githubStars?: number
+ latestVersion?: string
+ license?: string
+ lastUpdated?: string
+ supportedOs?: string[]
+ supportedAccelerators?: string[]
+ nodes: PackNode[]
+}
+
+export interface NodesSnapshot {
+ fetchedAt: string
+ packs: Pack[]
+}
+
+export function isNodesSnapshot(value: unknown): value is NodesSnapshot {
+ if (value === null || typeof value !== 'object') return false
+ const candidate = value as { fetchedAt?: unknown; packs?: unknown }
+ if (typeof candidate.fetchedAt !== 'string') return false
+ if (!Array.isArray(candidate.packs)) return false
+
+ return candidate.packs.every((pack) => {
+ if (pack === null || typeof pack !== 'object') return false
+ const p = pack as { id?: unknown; displayName?: unknown; nodes?: unknown }
+ return (
+ typeof p.id === 'string' &&
+ typeof p.displayName === 'string' &&
+ Array.isArray(p.nodes)
+ )
+ })
+}
diff --git a/apps/website/src/data/roles.ts b/apps/website/src/data/roles.ts
index d1e4cf075d..dd2896d28f 100644
--- a/apps/website/src/data/roles.ts
+++ b/apps/website/src/data/roles.ts
@@ -3,7 +3,7 @@ export interface Role {
title: string
department: string
location: string
- applyUrl: string
+ jobUrl: string
}
export interface Department {
diff --git a/apps/website/src/i18n/translations.ts b/apps/website/src/i18n/translations.ts
index 06342ace68..1bf6dfa1ff 100644
--- a/apps/website/src/i18n/translations.ts
+++ b/apps/website/src/i18n/translations.ts
@@ -735,6 +735,142 @@ const translations = {
'zh-CN': '免费试用 COMFY CLOUD'
},
+ 'cloudNodes.hero.label': {
+ en: 'CLOUD NODES',
+ 'zh-CN': '云端节点目录'
+ },
+ 'cloudNodes.hero.heading': {
+ en: 'Run your favorite ComfyUI custom nodes on the cloud',
+ 'zh-CN': '在云端运行你喜爱的 ComfyUI 自定义节点'
+ },
+ 'cloudNodes.hero.body': {
+ en: 'Spin up workflows with hundreds of community-built nodes — detailers, ControlNet preprocessors, animation tools, and quality-of-life utilities — preinstalled on Comfy Cloud and ready to run on managed GPUs.',
+ 'zh-CN':
+ '在 Comfy Cloud 托管 GPU 上即开即用,预装数百个社区节点——细节修复、ControlNet 预处理、动画工具与日常便利组件,应有尽有。'
+ },
+ 'cloudNodes.section.heading': {
+ en: 'Find a custom-node pack',
+ 'zh-CN': '查找自定义节点包'
+ },
+ 'cloudNodes.search.placeholder': {
+ en: 'Search packs or nodes',
+ 'zh-CN': '搜索节点包或节点名称'
+ },
+ 'cloudNodes.sort.downloads': {
+ en: 'Most installed',
+ 'zh-CN': '按安装量'
+ },
+ 'cloudNodes.sort.mostNodes': {
+ en: 'Most nodes',
+ 'zh-CN': '按节点数量'
+ },
+ 'cloudNodes.sort.az': {
+ en: 'A → Z',
+ 'zh-CN': '按名称 A → Z'
+ },
+ 'cloudNodes.sort.recentlyUpdated': {
+ en: 'Recently updated',
+ 'zh-CN': '最近更新'
+ },
+ 'cloudNodes.search.label': {
+ en: 'Search custom-node packs',
+ 'zh-CN': '搜索自定义节点包'
+ },
+ 'cloudNodes.sort.label': {
+ en: 'Sort packs',
+ 'zh-CN': '排序节点包'
+ },
+ 'cloudNodes.list.ariaLabel': {
+ en: 'Custom-node packs supported on Comfy Cloud',
+ 'zh-CN': 'Comfy Cloud 支持的自定义节点包'
+ },
+ 'cloudNodes.meta.title': {
+ en: 'Custom-node packs on Comfy Cloud — supported by default',
+ 'zh-CN': 'Comfy Cloud 自定义节点包合集——开箱即用'
+ },
+ 'cloudNodes.meta.description': {
+ en: 'Browse hundreds of ComfyUI custom-node packs preinstalled on Comfy Cloud. Detailers, ControlNet preprocessors, animation tools, samplers, and more — search by pack or by node name.',
+ 'zh-CN':
+ '浏览 Comfy Cloud 预装的数百个 ComfyUI 自定义节点包:细节修复、ControlNet 预处理、动画工具、采样器等——按节点包或节点名搜索。'
+ },
+ 'cloudNodes.detail.metaTitle': {
+ en: '{pack} on Comfy Cloud',
+ 'zh-CN': '{pack}(Comfy Cloud)'
+ },
+ 'cloudNodes.detail.metaDescription': {
+ en: '{pack} is preinstalled on Comfy Cloud — {nodeCount} nodes ready to run on managed GPUs. {description}',
+ 'zh-CN':
+ '{pack} 已预装于 Comfy Cloud——{nodeCount} 个节点可在托管 GPU 上即时运行。{description}'
+ },
+ 'cloudNodes.empty.heading': {
+ en: 'No matching packs',
+ 'zh-CN': '未找到匹配的节点包'
+ },
+ 'cloudNodes.empty.body': {
+ en: 'Try a different search term or clear your filters.',
+ 'zh-CN': '试试其他关键词,或清空筛选条件。'
+ },
+ 'cloudNodes.card.nodeCountOne': {
+ en: '{count} node',
+ 'zh-CN': '{count} 个节点'
+ },
+ 'cloudNodes.card.nodeCountOther': {
+ en: '{count} nodes',
+ 'zh-CN': '{count} 个节点'
+ },
+ 'cloudNodes.card.viewRepo': {
+ en: 'View repository',
+ 'zh-CN': '查看仓库'
+ },
+ 'cloudNodes.card.unavailableDescription': {
+ en: 'Description unavailable.',
+ 'zh-CN': '暂无描述信息。'
+ },
+ 'cloudNodes.card.nodesHeading': {
+ en: 'Included nodes',
+ 'zh-CN': '包含节点'
+ },
+ 'cloudNodes.detail.back': {
+ en: 'Back to all packs',
+ 'zh-CN': '返回所有节点包'
+ },
+ 'cloudNodes.detail.publisher': {
+ en: 'Publisher',
+ 'zh-CN': '发布者'
+ },
+ 'cloudNodes.detail.downloads': {
+ en: 'Downloads',
+ 'zh-CN': '下载量'
+ },
+ 'cloudNodes.detail.stars': {
+ en: 'GitHub stars',
+ 'zh-CN': 'GitHub 星标'
+ },
+ 'cloudNodes.detail.latestVersion': {
+ en: 'Latest version',
+ 'zh-CN': '最新版本'
+ },
+ 'cloudNodes.detail.license': {
+ en: 'License',
+ 'zh-CN': '许可证'
+ },
+ 'cloudNodes.detail.lastUpdated': {
+ en: 'Last updated',
+ 'zh-CN': '最后更新'
+ },
+ 'cloudNodes.detail.deprecated': {
+ en: 'Deprecated',
+ 'zh-CN': '已弃用'
+ },
+ 'cloudNodes.detail.experimental': {
+ en: 'Experimental',
+ 'zh-CN': '实验性'
+ },
+ 'cloudNodes.detail.nodesHeading': {
+ en: 'Nodes in this pack',
+ 'zh-CN': '此节点包中的节点'
+ },
+
// Cloud – ReasonSection
'cloud.reason.heading': {
en: 'Why\nprofessionals\nchoose ',
@@ -1119,6 +1255,10 @@ const translations = {
en: 'Import your own LoRAs',
'zh-CN': '导入你自己的 LoRA'
},
+ 'pricing.plan.creator.feature2': {
+ en: '3 concurrent API jobs',
+ 'zh-CN': '3 个并发 API 任务'
+ },
'pricing.plan.pro.label': { en: 'PRO', 'zh-CN': '专业版' },
'pricing.plan.pro.summary': {
@@ -1143,6 +1283,10 @@ const translations = {
en: 'Longer workflow runtime (up to 1 hour)',
'zh-CN': '更长工作流运行时长(最长 1 小时)'
},
+ 'pricing.plan.pro.feature2': {
+ en: '5 concurrent API jobs',
+ 'zh-CN': '5 个并发 API 任务'
+ },
'pricing.enterprise.label': { en: 'ENTERPRISE', 'zh-CN': '企业版' },
'pricing.enterprise.heading': {
@@ -1599,7 +1743,7 @@ const translations = {
},
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
- 'nav.blogs': { en: 'Blogs', 'zh-CN': '博客' },
+ 'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
'nav.github': { en: 'GitHub', 'zh-CN': 'GitHub' },
'nav.discord': { en: 'Discord', 'zh-CN': 'Discord' },
'nav.docs': { en: 'Docs', 'zh-CN': '文档' },
@@ -3498,18 +3642,6 @@ const translations = {
en: 'Dale Carman | Co-founder @ Groove Jones',
'zh-CN': 'Dale Carman | Groove Jones 联合创始人'
},
- 'customers.detail.groove-jones.topic-10.block.2.label': {
- en: 'GROOVE JONES CONTRIBUTORS',
- 'zh-CN': 'GROOVE JONES 贡献者'
- },
- 'customers.detail.groove-jones.topic-10.block.2.name': {
- en: 'TBD',
- 'zh-CN': '待补充'
- },
- 'customers.detail.groove-jones.topic-10.block.2.role': {
- en: 'TBD',
- 'zh-CN': '待补充'
- },
// Contact – FormSection
'contact.form.badge': {
@@ -3542,6 +3674,94 @@ const translations = {
'zh-CN': '我们会为您处理请求。'
},
+ 'demos.category.templates': { en: 'TEMPLATES', 'zh-CN': '模板' },
+ 'demos.category.gettingStarted': { en: 'GETTING STARTED', 'zh-CN': '入门' },
+
+ 'demos.image-to-video.title': {
+ en: 'Create a Video from an Image',
+ 'zh-CN': '从图片创建视频'
+ },
+ 'demos.image-to-video.description': {
+ en: 'Learn how to use the Image to Video workflow template in ComfyUI to generate short video clips from a single image.',
+ 'zh-CN':
+ '了解如何使用 ComfyUI 中的图片转视频工作流模板,从单张图片生成短视频。'
+ },
+ 'demos.image-to-video.transcript': {
+ en: '- Open ComfyUI — Launch the application and you\'ll see the node-based workflow canvas where all your AI pipelines are built.
- Browse templates — Click the workflow templates button in the sidebar to browse available starting points.
- Select Image to Video — Find and select the "Image to Video" template from the list to load it onto your canvas.
- Upload your image — Click the image upload node and select the source image you want to animate.
- Run the workflow — Click the "Queue" button to execute the workflow and generate your video output.
',
+ 'zh-CN':
+ '- 打开 ComfyUI — 启动应用程序,您将看到基于节点的工作流画布。
- 浏览模板 — 点击侧栏中的工作流模板按钮,浏览可用模板。
- 选择图片转视频 — 从列表中找到并选择"图片转视频"模板。
- 上传图片 — 点击图片上传节点,选择要动画化的源图片。
- 运行工作流 — 点击"排队"按钮执行工作流并生成视频输出。
'
+ },
+
+ 'demos.workflow-templates.title': {
+ en: 'Browse Workflow Templates',
+ 'zh-CN': '浏览工作流模板'
+ },
+ 'demos.workflow-templates.description': {
+ en: "Explore ComfyUI's built-in workflow templates to quickly get started with common AI generation tasks.",
+ 'zh-CN': '探索 ComfyUI 内置的工作流模板,快速开始常见的 AI 生成任务。'
+ },
+ 'demos.workflow-templates.transcript': {
+ en: '- Open the template browser — Click the templates icon in the ComfyUI sidebar to open the template library.
- Browse categories — Templates are organized by task: image generation, video, upscaling, and more.
- Preview a template — Hover over any template to see a preview of its workflow and expected output.
- Load and customize — Click to load a template, then modify parameters to fit your needs.
',
+ 'zh-CN':
+ '- 打开模板浏览器 — 点击 ComfyUI 侧栏中的模板图标。
- 浏览分类 — 模板按任务分类:图像生成、视频、放大等。
- 预览模板 — 将鼠标悬停在模板上查看预览。
- 加载并自定义 — 点击加载模板,然后修改参数。
'
+ },
+
+ 'demos.community-workflows.title': {
+ en: 'Explore and Use a Community Workflow from the Hub',
+ 'zh-CN': '探索并使用社区工作流'
+ },
+ 'demos.community-workflows.description': {
+ en: 'Discover how to find and get started with popular community workflows for generative AI projects.',
+ 'zh-CN': '了解如何查找并使用流行的社区工作流来构建生成式 AI 项目。'
+ },
+ 'demos.community-workflows.transcript': {
+ en: '- Open the Workflow Hub — From the ComfyUI sidebar, navigate to the community Workflow Hub to browse curated and trending workflows shared by the community.
- Browse popular workflows — Explore featured projects sorted by popularity, recency, and category to find one that matches your goal.
- Preview a workflow — Click a workflow card to see example outputs, required models, and a description of what it produces.
- Open in ComfyUI — Use the "Get Started" action to load the selected community workflow directly onto your canvas.
- Run and customize — Queue the workflow to generate your first result, then tweak prompts, models, and parameters to make it your own.
',
+ 'zh-CN':
+ '- 打开工作流中心 — 在 ComfyUI 侧栏中,进入社区工作流中心,浏览社区分享的精选和热门工作流。
- 浏览热门工作流 — 按热度、时间和分类浏览精选项目,找到符合需求的工作流。
- 预览工作流 — 点击工作流卡片,查看示例输出、所需模型和功能描述。
- 在 ComfyUI 中打开 — 使用"开始使用"按钮,将选中的社区工作流直接加载到画布。
- 运行并自定义 — 排队执行工作流以生成首个结果,然后调整提示词、模型和参数。
'
+ },
+
+ 'demos.nav.nextDemo': { en: "What's Next", 'zh-CN': '下一个演示' },
+ 'demos.nav.viewDemo': { en: 'View Demo', 'zh-CN': '查看演示' },
+ 'demos.nav.allDemos': { en: 'All Demos', 'zh-CN': '所有演示' },
+ 'demos.transcript.label': { en: 'Demo transcript', 'zh-CN': '演示文字记录' },
+ 'demos.transcript.note': {
+ en: '(for accessibility & search)',
+ 'zh-CN': '(无障碍和搜索)'
+ },
+ 'demos.loading': {
+ en: 'Loading interactive demo…',
+ 'zh-CN': '正在加载互动演示…'
+ },
+ 'demos.noscript': {
+ en: 'This interactive demo requires JavaScript.',
+ 'zh-CN': '此互动演示需要 JavaScript。'
+ },
+ 'demos.noscript.link': {
+ en: 'View on Arcade →',
+ 'zh-CN': '在 Arcade 上查看 →'
+ },
+ 'demos.duration.2min': { en: '~2 min', 'zh-CN': '~2 分钟' },
+ 'demos.difficulty.beginner': { en: 'Beginner', 'zh-CN': '入门' },
+ 'demos.difficulty.intermediate': {
+ en: 'Intermediate',
+ 'zh-CN': '中级'
+ },
+ 'demos.difficulty.advanced': { en: 'Advanced', 'zh-CN': '高级' },
+ 'demos.embed.label': {
+ en: 'Interactive demo',
+ 'zh-CN': '互动演示'
+ },
+ 'demos.comingSoon.title': {
+ en: 'Coming Soon',
+ 'zh-CN': '即将推出'
+ },
+ 'demos.comingSoon.body': {
+ en: 'This page is being redesigned. Check back soon.',
+ 'zh-CN': '此页面正在重新设计中,请稍后再来。'
+ },
+ 'demos.breadcrumb.home': { en: 'Home', 'zh-CN': '首页' },
+ 'demos.breadcrumb.demos': { en: 'Demos', 'zh-CN': '演示' },
+
'customers.story.whatsNext': {
en: "What's next?",
'zh-CN': '接下来看什么?'
@@ -3592,6 +3812,103 @@ const translations = {
'customers.feedback.role3': {
en: 'Head of AI at Creative Studios',
'zh-CN': 'Creative Studios AI 负责人'
+ },
+
+ // Models – UI keys
+ 'models.hero.eyebrow': {
+ en: 'AI Model',
+ 'zh-CN': 'AI 模型'
+ },
+ 'models.hero.primaryCta': {
+ en: 'TRY IN COMFY',
+ 'zh-CN': '在 Comfy 中试用'
+ },
+ 'models.hero.secondaryCta': {
+ en: 'DOWNLOAD MODEL',
+ 'zh-CN': '下载模型'
+ },
+ 'models.hero.cloudCta': {
+ en: 'RUN ON CLOUD',
+ 'zh-CN': '云端运行'
+ },
+ 'models.hero.tutorialCta': {
+ en: 'VIEW TUTORIAL',
+ 'zh-CN': '查看教程'
+ },
+ 'models.hero.blogLink': {
+ en: 'Read blog post',
+ 'zh-CN': '阅读博客文章'
+ },
+ 'models.hero.workflowCount': {
+ en: '{count} workflows use this model',
+ 'zh-CN': '{count} 个工作流使用此模型'
+ },
+ 'models.whatIs.heading': {
+ en: 'What is {name}?',
+ 'zh-CN': '什么是 {name}?'
+ },
+ 'models.whatIs.tutorialLink': {
+ en: 'Read the full tutorial →',
+ 'zh-CN': '阅读完整教程 →'
+ },
+ 'models.index.title': {
+ en: 'Supported Models',
+ 'zh-CN': '支持的模型'
+ },
+ 'models.index.subtitle': {
+ en: "Run the world's leading AI models in ComfyUI",
+ 'zh-CN': '在 ComfyUI 中运行世界领先的 AI 模型'
+ },
+ 'models.breadcrumb.home': {
+ en: 'Home',
+ 'zh-CN': '首页'
+ },
+ 'models.breadcrumb.models': {
+ en: 'Supported Models',
+ 'zh-CN': '支持的模型'
+ },
+
+ // Payment status pages
+ 'payment.success.label': {
+ en: 'PAYMENT',
+ 'zh-CN': '支付'
+ },
+ 'payment.success.title': {
+ en: 'Payment successful',
+ 'zh-CN': '支付成功'
+ },
+ 'payment.success.subtitle': {
+ en: "Thanks for your purchase. Your account has been credited and you're ready to keep building.",
+ 'zh-CN': '感谢您的购买。您的账户已充值完成,可以继续创作了。'
+ },
+ 'payment.success.primaryCta': {
+ en: 'CONTINUE TO COMFY CLOUD',
+ 'zh-CN': '前往 COMFY CLOUD'
+ },
+ 'payment.success.secondaryCta': {
+ en: 'VIEW USAGE & PAYMENTS',
+ 'zh-CN': '查看用量与支付'
+ },
+ 'payment.failed.label': {
+ en: 'PAYMENT',
+ 'zh-CN': '支付'
+ },
+ 'payment.failed.title': {
+ en: 'Unable to complete payment',
+ 'zh-CN': '无法完成支付'
+ },
+ 'payment.failed.subtitle': {
+ en: "Your payment didn't go through and you have not been charged. Reach out to support or read the subscription docs if you need help.",
+ 'zh-CN':
+ '您的支付未能完成,未发生扣款。如需帮助,请联系支持或查阅订阅文档。'
+ },
+ 'payment.failed.primaryCta': {
+ en: 'CONTACT SUPPORT',
+ 'zh-CN': '联系支持'
+ },
+ 'payment.failed.secondaryCta': {
+ en: 'READ SUBSCRIPTION DOCS',
+ 'zh-CN': '查看订阅文档'
}
} as const satisfies Record>
diff --git a/apps/website/src/layouts/BaseLayout.astro b/apps/website/src/layouts/BaseLayout.astro
index 77cf76131c..d46e85c7b3 100644
--- a/apps/website/src/layouts/BaseLayout.astro
+++ b/apps/website/src/layouts/BaseLayout.astro
@@ -5,11 +5,13 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import SiteNav from '../components/common/SiteNav.vue'
+import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
interface Props {
title: string
description?: string
+ keywords?: string[]
ogImage?: string
noindex?: boolean
}
@@ -17,10 +19,13 @@ interface Props {
const {
title,
description = 'Comfy is the AI creation engine for visual professionals who demand control.',
+ keywords,
ogImage = 'https://media.comfy.org/website/comfy.webp',
noindex = false,
} = Astro.props
+const keywordsContent = keywords && keywords.length > 0 ? keywords.join(', ') : undefined
+
const siteBase = Astro.site ?? 'https://comfy.org'
const canonicalURL = new URL(Astro.url.pathname, siteBase)
const ogImageURL = new URL(ogImage, siteBase)
@@ -62,6 +67,7 @@ const websiteJsonLd = {
+ {keywordsContent && }
{noindex && }
{title}
@@ -89,8 +95,11 @@ const websiteJsonLd = {
-
-
+
+
+
+
+
{gtmEnabled && (
@@ -109,6 +118,7 @@ const websiteJsonLd = {
)}
+
{gtmEnabled && (
diff --git a/apps/website/src/pages/cloud/index.astro b/apps/website/src/pages/cloud/index.astro
index bf2268943b..889a98adb8 100644
--- a/apps/website/src/pages/cloud/index.astro
+++ b/apps/website/src/pages/cloud/index.astro
@@ -7,9 +7,14 @@ import AudienceSection from '../../components/product/cloud/AudienceSection.vue'
import PricingSection from '../../components/product/cloud/PricingSection.vue'
import ProductCardsSection from '../../components/product/cloud/ProductCardsSection.vue'
import FAQSection from '../../components/product/cloud/FAQSection.vue'
+import { t } from '../../i18n/translations'
---
-
+
diff --git a/apps/website/src/pages/cloud/supported-nodes.astro b/apps/website/src/pages/cloud/supported-nodes.astro
new file mode 100644
index 0000000000..5d1e14aae5
--- /dev/null
+++ b/apps/website/src/pages/cloud/supported-nodes.astro
@@ -0,0 +1,42 @@
+---
+import BaseLayout from '../../layouts/BaseLayout.astro'
+import HeroSection from '../../components/cloud-nodes/HeroSection.vue'
+import PackGridSection from '../../components/cloud-nodes/PackGridSection.vue'
+import { t } from '../../i18n/translations'
+import { loadPacksForBuild } from '../../utils/cloudNodes.build'
+import { escapeJsonLd } from '../../utils/escapeJsonLd'
+
+const packs = await loadPacksForBuild()
+
+const siteBase = Astro.site ?? new URL('https://comfy.org')
+const pageUrl = new URL('/cloud/supported-nodes', siteBase).href
+
+const itemListJsonLd = {
+ '@context': 'https://schema.org',
+ '@type': 'ItemList',
+ name: 'Custom-node packs supported on Comfy Cloud',
+ url: pageUrl,
+ numberOfItems: packs.length,
+ itemListElement: packs.map((pack, index) => ({
+ '@type': 'ListItem',
+ position: index + 1,
+ url: new URL(`/cloud/supported-nodes/${pack.id}`, siteBase).href,
+ name: pack.displayName,
+ image: pack.bannerUrl || pack.iconUrl
+ }))
+}
+---
+
+
+
+
+
+
diff --git a/apps/website/src/pages/cloud/supported-nodes/AGENTS.md b/apps/website/src/pages/cloud/supported-nodes/AGENTS.md
new file mode 100644
index 0000000000..0ae27dd0b6
--- /dev/null
+++ b/apps/website/src/pages/cloud/supported-nodes/AGENTS.md
@@ -0,0 +1,48 @@
+# Cloud Nodes Pages
+
+Build-time catalog of custom-node packs preinstalled on Comfy Cloud. Index at `/cloud/supported-nodes`, per-pack details at `/cloud/supported-nodes/[pack]`, both also under `/zh-CN/`.
+
+## Sources
+
+- **Cloud `/api/object_info`** — authoritative list of nodes available on Comfy Cloud (auth: `WEBSITE_CLOUD_API_KEY`)
+- **ComfyUI Custom Node Registry** ([dashboard](https://registry.comfy.org), API at `https://api.comfy.org/nodes`) — public pack metadata (banner, icon, description, downloads, stars, license, version, repo, publisher)
+
+The registry is the same one the in-app Manager dialog reads from. For reference and additional reading, see the existing client wrappers in `src/`:
+
+- [`src/services/comfyRegistryService.ts`](../../../../../../src/services/comfyRegistryService.ts) — typed wrappers around `/nodes`, `/nodes/search`, `/nodes/{id}`, `/nodes/{id}/versions/{version}/comfy-nodes`, etc.
+- [`src/stores/comfyRegistryStore.ts`](../../../../../../src/stores/comfyRegistryStore.ts) — cached store + `getPacksByIds` batch helper
+- [`packages/registry-types/src/comfyRegistryTypes.ts`](../../../../../../packages/registry-types/src/comfyRegistryTypes.ts) — generated OpenAPI types
+- Public docs:
+
+## Build pipeline
+
+| File | Role |
+| ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
+| [`pages/cloud/supported-nodes.astro`](./supported-nodes.astro) and [`[pack].astro`](./supported-nodes/%5Bpack%5D.astro) | Page shells (and `zh-CN` twins) |
+| [`utils/cloudNodes.build.ts`](../../../utils/cloudNodes.build.ts) | `loadPacksForBuild()` shared by index + detail routes |
+| [`utils/cloudNodes.ts`](../../../utils/cloudNodes.ts) | Cloud `object_info` fetcher with retry, sanitization, snapshot fallback |
+| [`utils/cloudNodes.registry.ts`](../../../utils/cloudNodes.registry.ts) | Registry enrichment (batches of 50, soft-fail) |
+| [`utils/cloudNodes.ci.ts`](../../../utils/cloudNodes.ci.ts) | GitHub Actions annotations + step summary |
+| [`utils/escapeJsonLd.ts`](../../../utils/escapeJsonLd.ts) | XSS-safe `` or ` proxyWidgets[properties.proxyWidgets]
+ workflow --> hostValues[host widgets_values]
+ proxyWidgets --> promotionStore[PromotionStore / promotion runtime]
+ promotionStore --> sourceWidget[Interior source widget]
+ linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget]
+ sourceWidget --> hostWidget
+ hostValues --> hostWidget
+ hostWidget --> prompt[Prompt serialization]
+ hostWidget -. may copy value back .-> sourceWidget
+ sourceWidget -. shared by host instances .-> otherHost[Another host instance]
+
+ classDef legacy fill:#fff3cd,stroke:#a66f00,color:#332200
+ classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000
+ classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
+
+ class proxyWidgets,promotionStore legacy
+ class sourceWidget,hostValues ambiguous
+ class linkedInput,hostWidget canonical
+```
+
+Key problems in the old flow:
+
+- `properties.proxyWidgets` and linked `SubgraphInput` widgets could describe
+ the same promotion.
+- Interior source widgets supplied both schema metadata and, in some flows,
+ persisted host values.
+- Multiple host instances of the same subgraph could stomp one another through
+ the shared interior widget value.
+- Display-only previews were mixed into widget-promotion language even though
+ they do not own values or feed prompt serialization.
+
+## After: linked inputs are the promoted-widget boundary
+
+Promoted value widgets are now represented only as standard linked
+`SubgraphInput` widgets. The source widget remains the schema/default provider,
+but the host `SubgraphNode` owns the promoted value.
+
+```mermaid
+flowchart TD
+ workflow[Workflow JSON] --> subgraphInterface[Subgraph interface / inputs]
+ workflow --> hostValues[host widgets_values]
+ subgraphInterface --> subgraphInput[SubgraphInput.name]
+ subgraphInput --> hostWidget[Host-scoped widget entity]
+ hostValues --> hostWidget
+ sourceWidget[Interior source widget] --> schema[Schema, type, options, tooltip, default]
+ schema --> hostWidget
+ hostWidget --> prompt[Prompt serialization]
+
+ hostIdentity[Host node locator + SubgraphInput.name] --> hostWidget
+ sourceWidget -. metadata only .-> diagnostics[Diagnostics / lookup / migration]
+ sourceWidget -. no host value ownership .-> schema
+
+ classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
+ classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
+
+ class subgraphInterface,subgraphInput,hostWidget,hostIdentity owner
+ class sourceWidget,schema,diagnostics metadata
+ class workflow,hostValues persisted
+```
+
+Canonical ownership after the migration:
+
+- UI/value identity is host-scoped: host node locator plus
+ `SubgraphInput.name`.
+- `SubgraphInput.name` is stable identity; labels and localized names are
+ display-only.
+- Host values win during repair, persistence, and prompt serialization.
+- Source widgets provide metadata and defaults only.
+- Canonical saves omit repaired `properties.proxyWidgets` entries.
+
+## Legacy load migration
+
+Loading a workflow with legacy `proxyWidgets` performs an idempotent repair. The
+repair builds a plan before mutating graph state, then re-resolves against the
+current graph when node IDs and links are stable.
+
+```mermaid
+flowchart TD
+ start[Load workflow] --> parse{Parse properties.proxyWidgets}
+ parse -->|invalid raw data| invalid[console.error and ignore]
+ parse -->|valid tuples| plan[Build repair plan]
+ plan --> classify{Classify entry}
+
+ classify -->|value widget| valueRepair{Already linked SubgraphInput?}
+ valueRepair -->|yes| consume[Consume legacy proxy entry]
+ valueRepair -->|no| repair[Repair through subgraph input/link systems]
+ repair --> repairResult{Repair succeeded?}
+ repairResult -->|yes| consume
+ repairResult -->|no| quarantine[Persist proxyWidgetErrorQuarantine]
+
+ classify -->|primitive fanout| primitive[Validate all primitive targets]
+ primitive --> primitiveResult{All targets reconnectable?}
+ primitiveResult -->|yes| primitiveRepair[Create one SubgraphInput and reconnect fanout]
+ primitiveRepair --> consume
+ primitiveResult -->|no| quarantine
+
+ classify -->|display-only preview| preview[Create / keep previewExposures entry]
+ preview --> consume
+
+ consume --> save[Canonical save]
+ quarantine --> save
+ save --> omit[Omit repaired entries from proxyWidgets]
+ save --> keepQuarantine[Persist unrepaired value intent in quarantine]
+ save --> keepPreview[Persist previews in previewExposures]
+
+ classDef ok fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef warn fill:#fff3cd,stroke:#a66f00,color:#332200
+ classDef error fill:#f8d7da,stroke:#842029,color:#330000
+ classDef neutral fill:#e2e3e5,stroke:#41464b,color:#212529
+
+ class consume,repair,primitiveRepair,preview,save,omit,keepPreview ok
+ class plan,classify,valueRepair,primitive,primitiveResult,repairResult neutral
+ class quarantine,keepQuarantine warn
+ class invalid error
+```
+
+## Preview exposures are separate from value widgets
+
+Display-only previews, such as `$$canvas-image-preview`, are not promoted
+widgets. They have host-scoped serialized identity, but they do not create
+prompt inputs, do not create `widgets_values`, and do not own user values.
+
+```mermaid
+flowchart TD
+ hostNode[Host SubgraphNode] --> previewExposures[properties.previewExposures]
+ previewExposures --> exposure[PreviewExposure.name]
+ exposure --> sourceLocator[sourceNodeId + sourcePreviewName]
+ sourceLocator --> runtimePreview[Runtime preview/output state]
+ runtimePreview --> hostCanvas[Host canvas / app-mode preview]
+
+ exposure --> uiIdentity[hostNodeLocator + previewName]
+ runtimePreview -. UI projection only .-> hostCanvas
+ previewExposures -. no prompt input .-> noPrompt[No prompt serialization]
+ previewExposures -. no value widget .-> noValue[No widgets_values entry]
+ previewExposures -. no graph edge .-> noEdge[No executable graph edge]
+
+ classDef preview fill:#cff4fc,stroke:#055160,color:#032830
+ classDef noValue fill:#f8d7da,stroke:#842029,color:#330000
+ classDef persisted fill:#e2e3e5,stroke:#41464b,color:#212529
+
+ class previewExposures,exposure,sourceLocator,runtimePreview,hostCanvas,uiIdentity preview
+ class noPrompt,noValue,noEdge noValue
+ class hostNode persisted
+```
+
+For nested subgraphs, preview exposures chain across immediate host boundaries
+instead of persisting flattened deep paths.
+
+```mermaid
+flowchart LR
+ outerHost[Outer SubgraphNode] --> outerExposure[Outer previewExposures entry]
+ outerExposure --> innerHost[Immediate inner SubgraphNode]
+ innerHost --> innerExposure[Inner previewExposures entry]
+ innerExposure --> deepestPreview[Interior preview source]
+ deepestPreview --> media[Resolved media]
+
+ outerExposure -. sourcePreviewName names inner preview identity .-> innerExposure
+ outerExposure -. does not persist deep private path .-> opaque[Subgraph internals remain opaque]
+
+ classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef preview fill:#cff4fc,stroke:#055160,color:#032830
+ classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
+
+ class outerHost,innerHost boundary
+ class outerExposure,innerExposure,deepestPreview,media preview
+ class opaque note
+```
+
+## Serialization summary
+
+```mermaid
+flowchart TD
+ canonical[Canonical serialized SubgraphNode] --> inputs[Subgraph interface / inputs]
+ canonical --> values[widgets_values for host-owned values]
+ canonical --> previews[properties.previewExposures]
+ canonical --> quarantine[properties.proxyWidgetErrorQuarantine]
+ canonical -. omits repaired entries .-> noProxy[No canonical proxyWidgets]
+
+ inputs --> valueWidgets[Promoted value widgets]
+ values --> valueWidgets
+ previews --> previewUi[Display-only preview UI]
+ quarantine --> futureTooling[Future recovery tooling]
+
+ valueWidgets --> prompt[Prompt serialization]
+ previewUi -. not serialized into prompt .-> prompt
+ quarantine -. inert .-> prompt
+
+ classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef inert fill:#fff3cd,stroke:#a66f00,color:#332200
+ classDef removed fill:#f8d7da,stroke:#842029,color:#330000
+
+ class inputs,values,valueWidgets,prompt,canonical canonical
+ class previews,previewUi,quarantine,futureTooling inert
+ class noProxy removed
+```
diff --git a/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md
new file mode 100644
index 0000000000..f9cb51b1f4
--- /dev/null
+++ b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/disambiguating-source-node-id.md
@@ -0,0 +1,147 @@
+# Appendix: Removing `disambiguatingSourceNodeId`
+
+This appendix explains where the existing promotion system needs
+`disambiguatingSourceNodeId`, why that need appears, and how the canonical form
+chosen by [ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md)
+removes the pattern from promoted-widget identity.
+
+## Why the disambiguator exists
+
+The legacy promotion model identifies a promoted widget by source location:
+
+```ts
+type PromotedWidgetSource = {
+ sourceNodeId: string
+ sourceWidgetName: string
+ disambiguatingSourceNodeId?: string
+}
+```
+
+`sourceNodeId` is the immediate interior node visible from the host subgraph.
+That is not always the original widget owner. When promotions pass through
+nested subgraphs, two promoted widgets can have the same immediate
+`sourceNodeId` and `sourceWidgetName` while pointing at different leaf widgets.
+`disambiguatingSourceNodeId` carries the deepest source node ID so the runtime
+can choose the right promoted view.
+
+```mermaid
+flowchart TD
+ outerHost[Outer host SubgraphNode] --> middleNode[Interior middle SubgraphNode]
+ middleNode --> middleWidgetA[Promoted widget view: text]
+ middleNode --> middleWidgetB[Promoted widget view: text]
+ middleWidgetA --> leafA[Leaf source node 17 / widget text]
+ middleWidgetB --> leafB[Leaf source node 42 / widget text]
+
+ oldKeyA[Old key: middleNodeId + text + disambiguatingSourceNodeId 17]
+ oldKeyB[Old key: middleNodeId + text + disambiguatingSourceNodeId 42]
+ middleWidgetA -. requires .-> oldKeyA
+ middleWidgetB -. requires .-> oldKeyB
+
+ classDef host fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef ambiguous fill:#fff3cd,stroke:#a66f00,color:#332200
+ classDef leaf fill:#cff4fc,stroke:#055160,color:#032830
+
+ class outerHost host
+ class middleNode,middleWidgetA,middleWidgetB,oldKeyA,oldKeyB ambiguous
+ class leafA,leafB leaf
+```
+
+The disambiguator is therefore not a domain concept. It is compensating for an
+identity model that asks host UI state to identify private nested internals.
+
+## Existing places that need it
+
+| Area | Current use of `disambiguatingSourceNodeId` | Ambiguity being patched |
+| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| Promotion source types | `PromotedWidgetSource` and `PromotedWidgetView` carry the optional field. | Source identity needs more than immediate node ID plus widget name for nested promoted views. |
+| Concrete widget resolution | `findWidgetByIdentity(...)` matches promoted views by `(disambiguatingSourceNodeId ?? sourceNodeId)` when a source node ID is supplied. | Multiple promoted views under the same intermediate node can share a widget name. |
+| Legacy proxy normalization | Prefixed legacy names such as `123:widget_name` are converted into structured source identity and tested with candidate disambiguators. | Old serialized names encode leaf identity inside the widget name string. |
+| Promotion store keys | `makePromotionEntryKey(...)`, `isPromoted(...)`, and `demote(...)` include the field in equality. | Store-level uniqueness would collapse distinct nested promotions without the leaf ID. |
+| Linked promotion propagation | `SubgraphNode._resolveLinkedPromotionBySubgraphInput(...)` preserves the leaf ID when a linked input targets an inner subgraph promoted view. | The outer host otherwise sees only the immediate inner `SubgraphNode` and the promoted widget name. |
+| Subgraph editor UI | The editor uses the field when resolving active widgets and when writing reordered/toggled promotions back to the store. | UI list operations must not merge same-name promoted views from different leaves. |
+
+## New promoted-widget identity
+
+ADR 0009 moves promoted value identity to the host boundary:
+
+```ts
+type PromotedWidgetUiIdentity = {
+ hostNodeLocator: string
+ subgraphInputName: string
+}
+```
+
+The canonical widget is owned by a `SubgraphInput` on the host
+`SubgraphNode`. The host widget no longer needs to identify the deepest source
+node to preserve value identity. The source widget is consulted for schema,
+defaults, diagnostics, and migration, but it is not the value owner.
+
+```mermaid
+flowchart TD
+ host[Host SubgraphNode] --> inputA[SubgraphInput.name: prompt]
+ host --> inputB[SubgraphInput.name: negative_prompt]
+ inputA --> hostWidgetA[Host-owned widget entity]
+ inputB --> hostWidgetB[Host-owned widget entity]
+
+ hostWidgetA -. schema/default metadata .-> sourceA[Interior source widget text]
+ hostWidgetB -. schema/default metadata .-> sourceB[Interior source widget text]
+
+ identityA[Identity: hostNodeLocator + prompt] --> hostWidgetA
+ identityB[Identity: hostNodeLocator + negative_prompt] --> hostWidgetB
+ sourceA -. not part of host value key .-> identityA
+ sourceB -. not part of host value key .-> identityB
+
+ classDef owner fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef metadata fill:#cff4fc,stroke:#055160,color:#032830
+ classDef removed fill:#f8d7da,stroke:#842029,color:#330000
+
+ class host,inputA,inputB,hostWidgetA,hostWidgetB,identityA,identityB owner
+ class sourceA,sourceB metadata
+```
+
+This is the same rule the subgraph interface already uses: `name` is stable
+identity, and `label` / `localized_name` are display-only.
+
+## How the new form removes each need
+
+| Previous disambiguation site | New canonical replacement |
+| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
+| `PromotedWidgetSource.disambiguatingSourceNodeId` | Host value identity is `hostNodeLocator + SubgraphInput.name`; source locator fields become migration/diagnostic metadata only. |
+| `PromotedWidgetView.disambiguatingSourceNodeId` | Host-scoped widget entities are derived from subgraph inputs, not from promoted views chained through nested source widgets. |
+| `findWidgetByIdentity(...)` leaf matching | Runtime value lookup starts from the host input identity; source traversal is metadata resolution, not value identity resolution. |
+| Legacy prefixed widget-name normalization | Load migration consumes legacy source-shaped entries and writes standard subgraph input state or quarantine metadata. |
+| PromotionStore source-key equality | `PromotionStore` becomes a temporary derived index; canonical consumers query subgraph inputs directly. |
+| Linked promotion propagation across nested hosts | Nested value composition is represented boundary-by-boundary by linked subgraph inputs with stable names. |
+| Subgraph editor active widget matching | Editor state can operate on host boundary entries instead of matching leaf source widgets through same-name promoted views. |
+
+## Boundary-by-boundary nested flow
+
+The new form avoids flattened deep source paths. Each host boundary exposes its
+own named input, and the next outer host links to that immediate boundary
+contract.
+
+```mermaid
+flowchart LR
+ leaf[Leaf node widget] --> innerInput[Inner SubgraphInput.name: text]
+ innerInput --> innerHostWidget[Inner host-owned widget]
+ innerHostWidget --> outerInput[Outer SubgraphInput.name: prompt]
+ outerInput --> outerHostWidget[Outer host-owned widget]
+
+ innerIdentity[Inner value key: innerHost + text] --> innerHostWidget
+ outerIdentity[Outer value key: outerHost + prompt] --> outerHostWidget
+ leaf -. schema/default source .-> innerHostWidget
+ leaf -. not persisted as outer value key .-> outerIdentity
+
+ classDef boundary fill:#d1e7dd,stroke:#0f5132,color:#052e16
+ classDef source fill:#cff4fc,stroke:#055160,color:#032830
+ classDef note fill:#fff3cd,stroke:#a66f00,color:#332200
+
+ class innerInput,innerHostWidget,outerInput,outerHostWidget,innerIdentity,outerIdentity boundary
+ class leaf source
+```
+
+Because each layer has its own stable `SubgraphInput.name`, two same-name leaf
+widgets no longer require a persisted leaf-node disambiguator at the outer host.
+If the user exposes both, the collision is resolved when the host inputs are
+created by assigning distinct input names with the existing unique-name
+behavior.
diff --git a/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md
new file mode 100644
index 0000000000..82265820e5
--- /dev/null
+++ b/docs/adr/0009-subgraph-promoted-widgets-use-linked-inputs/system-comparison.md
@@ -0,0 +1,37 @@
+# Appendix: System comparison
+
+This appendix compares the legacy promoted-widget systems with the canonical
+linked-input model chosen by
+[ADR 0009](../0009-subgraph-promoted-widgets-use-linked-inputs.md).
+
+| Concern | Legacy `properties.proxyWidgets` promotions | Linked `SubgraphInput` promotions before migration | New canonical linked-input system |
+| -------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
+| Serialized authority | `properties.proxyWidgets` stores source node/widget tuples as promotion topology. | Subgraph interface/input links can also represent the same exposed widget. | Subgraph interface/input links are the only canonical topology for promoted value widgets. |
+| Load-time role | Hydrates promoted widgets directly from legacy tuples. | May already describe the promoted widget, creating overlap with `proxyWidgets`. | Existing linked inputs are accepted as resolved; legacy tuples are consumed by repair or quarantined. |
+| Save-time role | Could be re-emitted as promotion state. | Serialized as normal subgraph interface data. | Repaired `proxyWidgets` entries are omitted; standard subgraph inputs plus host `widgets_values` are saved. |
+| Value owner | Ambiguous: host `widgets_values` and the interior source widget could both carry the value. | Closer to the desired boundary model, but still coexisted with source/proxy ownership paths. | Host `SubgraphNode` owns value state through host-scoped widget identity. |
+| Schema/default provider | Interior source widget provides schema and may also become persistence carrier. | Interior source widget provides source metadata through the link. | Interior source widget provides schema, type, options, tooltip, and defaults only. |
+| UI identity | Often persisted by source node/widget identity. | Can use subgraph input identity, but mixed states still exist while proxy identity remains. | Host node locator plus `SubgraphInput.name`. |
+| Display label handling | Source widget identity and display concerns can blur. | Uses existing subgraph input naming conventions. | `SubgraphInput.name` is stable identity; `label` / `localized_name` are display-only. |
+| Multiple host instances | Risk of host instances stomping one another through shared interior values. | Better host boundary shape, but overlap with proxy/source value paths can reintroduce ambiguity. | Host-instance-owned sparse overlay prevents shared interior widget value stomping. |
+| Prompt serialization | May read values through promoted runtime state that can collapse to source widgets. | Can serialize through standard subgraph input widgets when used consistently. | Promoted values serialize only through standard host-owned subgraph-input widgets. |
+| Interior mutation on save | Existing `SubgraphNode.serialize()` behavior could copy exterior values into connected interior widgets. | Could still be affected by legacy copy-back behavior. | Copy-back is removed; source widgets are not persistence carriers. |
+| Primitive-node promotions | Legacy tuples may point at `PrimitiveNode` outputs. | Not the canonical primitive fanout representation by itself. | Repaired all-or-nothing into one `SubgraphInput` that reconnects validated fanout targets. |
+| Invalid or unresolved data | Invalid data could sit in legacy promotion state or fail repair paths. | Missing linked inputs can be ambiguous when proxy data exists. | Invalid raw data logs and is ignored; unrepaired valid value entries go to `proxyWidgetErrorQuarantine`. |
+| Display-only previews | Often mixed into `proxyWidgets` despite not being value widgets. | Linked inputs are inappropriate because previews do not own values or prompt inputs. | Separate host-scoped `properties.previewExposures` entries model preview UI only. |
+| Preview persistence | Preview selections can depend on source preview/widget-like identity. | No clean distinction from promoted widget inputs. | Preview identity is host node locator plus `previewName`; unresolved previews stay inert and persisted. |
+| Nested preview behavior | Deep source identity can leak through host UI state. | Linked value inputs do not model display-only preview composition. | Preview exposures chain across immediate subgraph host boundaries; deep private paths are not persisted. |
+| ECS compatibility | Weak: value identity can depend on source widget tuples and mutable interior widgets. | Partial: linked inputs fit boundary modeling, but duplicate authority remains. | Strong: host-scoped widget entity identity maps cleanly to ECS component state. |
+| Long-term status | Legacy load-time input only. | Becomes the standard representation once overlap is removed. | Canonical system; `PromotionStore` becomes a temporary derived compatibility/index layer. |
+
+## Practical migration summary
+
+| Legacy shape | New result |
+| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
+| Valid `proxyWidgets` entry already represented by a linked `SubgraphInput` | Entry is consumed; the existing linked input remains canonical. |
+| Valid value-widget `proxyWidgets` entry without a linked input | Repair creates or reconnects standard subgraph input/link state. |
+| Valid primitive fanout entry | Repair creates one `SubgraphInput`, reconnects all validated targets, and leaves the primitive node inert. |
+| Valid value-widget entry that cannot be repaired | Entry is persisted in `properties.proxyWidgetErrorQuarantine` with the host value when available. |
+| Preview-shaped legacy entry | Entry is migrated into `properties.previewExposures`, not a linked input. |
+| Unresolved preview exposure | Entry remains inert in `previewExposures`; it is not quarantined because it owns no user value. |
+| Invalid raw `proxyWidgets` data | Logs `console.error`, does not throw, and is not quarantined. |
diff --git a/docs/architecture/ecs-migration-plan.md b/docs/architecture/ecs-migration-plan.md
index a3ba8fbe53..e6a1d1d61a 100644
--- a/docs/architecture/ecs-migration-plan.md
+++ b/docs/architecture/ecs-migration-plan.md
@@ -231,6 +231,11 @@ assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
the ID mapping — widgets currently lack independent IDs, so the bridge must
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
+**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
+host-boundary identity (`host node locator + SubgraphInput.name`). Interior
+source node/widget identity is preserved only as migration and diagnostic
+metadata.
+
### 2c. Read-only bridge for Node metadata
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
@@ -663,6 +668,10 @@ The 6 proto-ECS stores use 6 different keying strategies:
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
| SubgraphNavigationStore | subgraphId or `'root'` |
+ADR 0009 refines the promoted-widget target: promoted value widgets should use
+host boundary identity (`host node locator + SubgraphInput.name`), not interior
+source node/widget identity.
+
The World unifies these under branded entity IDs. But stores that use
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
reality — a widget is identified by its relationship to a node. Synthetic
diff --git a/docs/architecture/proto-ecs-stores.md b/docs/architecture/proto-ecs-stores.md
index 0831f0535d..f5f09ccb50 100644
--- a/docs/architecture/proto-ecs-stores.md
+++ b/docs/architecture/proto-ecs-stores.md
@@ -17,6 +17,10 @@ Six stores extract entity state out of class instances into centralized, queryab
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
+ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
+the host boundary (`host node locator + SubgraphInput.name`), while interior
+source node/widget identity is migration and diagnostic metadata only.
+
## 2. WidgetValueStore
**File:** `src/stores/widgetValueStore.ts`
@@ -254,6 +258,9 @@ Each store invents its own identity scheme:
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
+For promoted value widgets, ADR 0009 narrows the target key to host boundary
+identity (`host node locator + SubgraphInput.name`) instead of interior source
+identity.
## 6. Extraction Map
diff --git a/docs/architecture/subgraph-boundaries-and-promotion.md b/docs/architecture/subgraph-boundaries-and-promotion.md
index 51d7b8b941..0426a8afe1 100644
--- a/docs/architecture/subgraph-boundaries-and-promotion.md
+++ b/docs/architecture/subgraph-boundaries-and-promotion.md
@@ -404,26 +404,21 @@ Whichever candidate is chosen:
instance-specific state beyond inputs — must remain reachable. This is a
constraint, not a current requirement.
-### Recommendation and decision criteria
+### Decision
-**Lean toward A.** It eliminates an entire subsystem by recognizing a structural
-truth: promotion is adding a typed input to a function signature. The type
-system already handles widget creation for typed inputs. Building a parallel
-mechanism for "promoted widgets" is building a second, narrower version of
-something the system already does.
+[ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md)
+chooses Candidate A for promoted value widgets. It eliminates an entire
+subsystem by recognizing a structural truth: promotion is adding a typed input
+to a function signature. The type system already handles widget creation for
+typed inputs. Building a parallel mechanism for "promoted widgets" is building
+a second, narrower version of something the system already does.
The cost of A is a migration path for existing `proxyWidgets` serialization. On
-load, the `SerializationSystem` converts `proxyWidgets` entries into interface
-inputs and boundary links. This is a one-time ratchet conversion — once
-loaded and re-saved, the workflow uses the new format.
-
-**Choose B if** the team determines that promoted widgets must remain
-visually or behaviorally distinct from normal input widgets in ways the type →
-widget mapping cannot express, or if the `proxyWidgets` migration burden exceeds
-the current release cycle's capacity.
-
-**Decision needed before** Phase 3 of the ECS migration, when systems are
-introduced and the widget/connectivity architecture solidifies.
+load, the `SerializationSystem` converts value-widget `proxyWidgets` entries
+into interface inputs and boundary links. Once loaded and re-saved, the workflow
+uses the new format. ADR 0009 separates display-only preview exposures from
+promoted value widgets; those previews use their own host-scoped serialized
+representation instead of linked `SubgraphInput` widgets.
---
@@ -471,14 +466,14 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
see no change.
-| Direction | Format | Notes |
-| --------------- | ------------------------------- | ---------------------------------------- |
-| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
-| **Load/import** | Nested (current) or future flat | Ratchet: normalize to flat World on load |
+| Direction | Format | Notes |
+| --------------- | ------------------------------- | ------------------------------------------ |
+| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
+| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
-The "ratchet conversion" pattern: load any supported format, normalize to the
-internal model. The system accepts old formats indefinitely but produces the
-current format on save.
+The migration pattern: load any supported format and normalize to the internal
+model. The system accepts old formats indefinitely but produces the current
+format on save.
### Widget identity at the boundary
@@ -511,13 +506,12 @@ SubgraphIO {
}
```
-If Candidate A (connections-only promotion) is chosen: promoted widgets become
-interface inputs, serialized as additional `SubgraphIO` entries. On load, legacy
-`proxyWidgets` data is converted to interface inputs and boundary links (ratchet
-migration). On save, `proxyWidgets` is no longer written.
-
-If Candidate B (simplified promotion) is chosen: `proxyWidgets` continues to be
-serialized in its current format.
+ADR 0009 chooses Candidate A (connections-only promotion) for promoted value
+widgets: they become interface inputs, serialized as additional `SubgraphIO`
+entries. On load, legacy value-widget `proxyWidgets` data is converted to
+interface inputs and boundary links. On save, repaired `proxyWidgets` entries
+are no longer written. Display-only preview exposures use separate
+host-scoped `previewExposures` serialization.
### Backward-compatible loading contract
@@ -555,7 +549,7 @@ This document proposes or surfaces the following changes to
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
-| Widget promotion | Treated as a given feature to migrate | Open decision: Candidate A (connections-only) vs B (simplified component) |
+| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |
| Serialization | Not explicitly separated from internal model | Internal model ≠ wire format; `SerializationSystem` is the membrane |
| Backward compat | Implicit | Explicit contract: load any prior format, indefinitely |
diff --git a/docs/testing/unit-testing.md b/docs/testing/unit-testing.md
index a47012ffa2..181627fd65 100644
--- a/docs/testing/unit-testing.md
+++ b/docs/testing/unit-testing.md
@@ -147,7 +147,7 @@ it('should subscribe to logs API', () => {
})
```
-## Mocking Lodash Functions
+## Mocking Utility Functions
Mocking utility functions like debounce:
diff --git a/eslint.config.ts b/eslint.config.ts
index e2b06e3d0a..b5f36864b7 100644
--- a/eslint.config.ts
+++ b/eslint.config.ts
@@ -36,6 +36,7 @@ const settings = {
alwaysTryTypes: true,
project: [
'./tsconfig.json',
+ './browser_tests/tsconfig.json',
'./apps/*/tsconfig.json',
'./packages/*/tsconfig.json'
],
@@ -250,7 +251,13 @@ export default defineConfig([
// @/utils/errorUtil instead — see issue #11429.
selector: "TSAsExpression TSTypeReference[typeName.name='Error']",
message:
- 'Do not use `as Error` assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
+ 'Do not use Error type assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
+ },
+ {
+ // Bans `value` and `value`.
+ selector: "TSTypeAssertion TSTypeReference[typeName.name='Error']",
+ message:
+ 'Do not use Error type assertions. Use `instanceof Error` narrowing or `toError()` from @/utils/errorUtil instead. See issue #11429.'
}
]
}
diff --git a/lint-staged.config.ts b/lint-staged.config.ts
index 97a95ddd2f..d5f69648f1 100644
--- a/lint-staged.config.ts
+++ b/lint-staged.config.ts
@@ -34,7 +34,7 @@ function formatAndEslint(fileNames: string[]) {
const joinedPaths = toJoinedRelativePaths(fileNames)
return [
`pnpm exec oxfmt --write ${joinedPaths}`,
- `pnpm exec oxlint --fix ${joinedPaths}`,
+ `pnpm exec oxlint --type-aware --fix ${joinedPaths}`,
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
]
}
diff --git a/package.json b/package.json
index 2748b0cb63..e50b597347 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
- "version": "1.44.15",
+ "version": "1.45.6",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",
@@ -11,7 +11,7 @@
"build:cloud": "cross-env DISTRIBUTION=cloud NODE_OPTIONS='--max-old-space-size=8192' nx build",
"build:desktop": "nx build @comfyorg/desktop-ui",
"build-storybook": "storybook build",
- "build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
+ "build:types": "cross-env NODE_OPTIONS='--max-old-space-size=8192' nx build --config vite.types.config.mts && node scripts/prepare-types.js",
"build:analyze": "cross-env ANALYZE_BUNDLE=true pnpm build",
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
"size:collect": "node scripts/size-collect.js",
@@ -60,6 +60,7 @@
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "catalog:",
"@comfyorg/design-system": "workspace:*",
+ "@comfyorg/object-info-parser": "workspace:*",
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
diff --git a/packages/design-system/src/css/style.css b/packages/design-system/src/css/style.css
index 5106ed90e9..767e8c25bc 100644
--- a/packages/design-system/src/css/style.css
+++ b/packages/design-system/src/css/style.css
@@ -16,7 +16,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
-@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
+@source inline("icon-[comfy--{ai-model,anthropic,bfl,bria,bytedance,credits,elevenlabs,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver}]");
/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
@source inline("icon-[comfy--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");
diff --git a/packages/design-system/src/icons/anthropic.svg b/packages/design-system/src/icons/anthropic.svg
new file mode 100644
index 0000000000..f9f90c67c9
--- /dev/null
+++ b/packages/design-system/src/icons/anthropic.svg
@@ -0,0 +1 @@
+
diff --git a/packages/ingest-types/src/index.ts b/packages/ingest-types/src/index.ts
index 94fb077aa3..03a4f5ca9c 100644
--- a/packages/ingest-types/src/index.ts
+++ b/packages/ingest-types/src/index.ts
@@ -29,6 +29,17 @@ export type {
BillingStatus,
BillingStatusResponse,
BindingErrorResponse,
+ BulkRevokeApiKeysResponse,
+ BulkRevokeWorkspaceMemberApiKeysData,
+ BulkRevokeWorkspaceMemberApiKeysError,
+ BulkRevokeWorkspaceMemberApiKeysErrors,
+ BulkRevokeWorkspaceMemberApiKeysResponse,
+ BulkRevokeWorkspaceMemberApiKeysResponses,
+ CancelJobData,
+ CancelJobError,
+ CancelJobErrors,
+ CancelJobResponse,
+ CancelJobResponses,
CancelSubscriptionData,
CancelSubscriptionError,
CancelSubscriptionErrors,
@@ -307,6 +318,28 @@ export type {
GetJwksData,
GetJwksResponse,
GetJwksResponses,
+ GetLegacyAssetContentData,
+ GetLegacyAssetContentErrors,
+ GetLegacyHistoryByIdData,
+ GetLegacyHistoryByIdErrors,
+ GetLegacyHistoryData,
+ GetLegacyHistoryErrors,
+ GetLegacyJobByIdData,
+ GetLegacyJobByIdErrors,
+ GetLegacyJobOutputsData,
+ GetLegacyJobOutputsErrors,
+ GetLegacyModelsByFolderData,
+ GetLegacyModelsByFolderErrors,
+ GetLegacyModelsData,
+ GetLegacyModelsErrors,
+ GetLegacyObjectInfoByNodeClassData,
+ GetLegacyObjectInfoByNodeClassErrors,
+ GetLegacyPromptByIdData,
+ GetLegacyPromptByIdErrors,
+ GetLegacyUserdataV2Data,
+ GetLegacyUserdataV2Errors,
+ GetLegacyViewMetadataData,
+ GetLegacyViewMetadataErrors,
GetLogsData,
GetLogsError,
GetLogsErrors,
@@ -505,6 +538,7 @@ export type {
InterruptJobError,
InterruptJobErrors,
InterruptJobResponses,
+ JobCancelResponse,
JobDetailResponse,
JobEntry,
JobsListResponse,
@@ -719,6 +753,13 @@ export type {
SubscribeResponses,
SubscriptionDuration,
SubscriptionTier,
+ SyncApiKeyData,
+ SyncApiKeyError,
+ SyncApiKeyErrors,
+ SyncApiKeyRequest,
+ SyncApiKeyResponse,
+ SyncApiKeyResponse2,
+ SyncApiKeyResponses,
SystemStatsResponse,
TagInfo,
TagsModificationResponse,
diff --git a/packages/ingest-types/src/types.gen.ts b/packages/ingest-types/src/types.gen.ts
index 5af8c4b07d..c0cf784658 100644
--- a/packages/ingest-types/src/types.gen.ts
+++ b/packages/ingest-types/src/types.gen.ts
@@ -4,6 +4,9 @@ export type ClientOptions = {
baseUrl: `${string}://${string}` | (string & {})
}
+/**
+ * Response indicating whether a Hub username is available.
+ */
export type HubUsernameCheckResponse = {
/**
* The username that was checked.
@@ -23,6 +26,9 @@ export type HubUsernameCheckResponse = {
validation_error?: string
}
+/**
+ * Response containing a signed upload URL and the target asset path.
+ */
export type HubAssetUploadUrlResponse = {
/**
* Presigned R2 URL for uploading the file via PUT.
@@ -38,6 +44,9 @@ export type HubAssetUploadUrlResponse = {
token: string
}
+/**
+ * Request body for requesting a signed upload URL for a Hub asset.
+ */
export type HubAssetUploadUrlRequest = {
/**
* Original filename for display purposes. Not used in the storage key.
@@ -116,6 +125,9 @@ export type UpdateHubWorkflowRequest = {
} | null
}
+/**
+ * Request body for publishing or updating a workflow on the Hub.
+ */
export type PublishHubWorkflowRequest = {
/**
* Username of the hub profile to publish under. The authenticated user must belong to the workspace that owns this profile.
@@ -177,6 +189,9 @@ export type PublishHubWorkflowRequest = {
sample_image_tokens_or_urls?: Array
}
+/**
+ * Paginated list of Hub workflows matching search criteria.
+ */
export type HubWorkflowListResponse = {
/**
* Array of HubWorkflowSummary (default) or HubWorkflowDetail (when detail=true).
@@ -188,6 +203,9 @@ export type HubWorkflowListResponse = {
next_cursor?: string
}
+/**
+ * Lightweight asset reference used in workflow publishing payloads.
+ */
export type AssetInfo = {
/**
* Asset identifier.
@@ -213,6 +231,9 @@ export type AssetInfo = {
in_library: boolean
}
+/**
+ * Full Hub workflow detail including versions, assets, and statistics.
+ */
export type HubWorkflowDetail = {
share_id: string
workflow_id: string
@@ -241,6 +262,9 @@ export type HubWorkflowDetail = {
profile: HubProfileSummary
}
+/**
+ * Abbreviated Hub profile used in workflow listings.
+ */
export type HubProfileSummary = {
username: string
display_name?: string
@@ -250,6 +274,9 @@ export type HubProfileSummary = {
avatar_url?: string
}
+/**
+ * Reference to a Hub label by ID.
+ */
export type LabelRef = {
/**
* Slug identifier (e.g. "video-generation", "flux").
@@ -270,6 +297,9 @@ export type HubWorkflowStatus =
| 'rejected'
| 'deprecated'
+/**
+ * Abbreviated Hub workflow metadata used in search and listing results.
+ */
export type HubWorkflowSummary = {
share_id: string
name: string
@@ -290,6 +320,9 @@ export type HubWorkflowSummary = {
sample_image_urls?: Array
}
+/**
+ * Metadata for a single Hub label.
+ */
export type HubLabelInfo = {
/**
* Slug identifier.
@@ -309,6 +342,9 @@ export type HubLabelInfo = {
type: 'tag' | 'model' | 'custom_node'
}
+/**
+ * List of available Hub labels for categorizing workflows.
+ */
export type HubLabelListResponse = {
/**
* Available labels filtered by type (or all if no type specified).
@@ -316,6 +352,9 @@ export type HubLabelListResponse = {
labels: Array
}
+/**
+ * Entry in the curated workflow template gallery shown on the home page.
+ */
export type HubWorkflowTemplateEntry = {
/**
* Slug identifier for the template
@@ -410,6 +449,9 @@ export type HubWorkflowTemplateEntry = {
contentTemplate?: string
}
+/**
+ * Request body for updating an existing Hub profile.
+ */
export type UpdateHubProfileRequest = {
display_name?: string
description?: string
@@ -424,6 +466,9 @@ export type UpdateHubProfileRequest = {
website_urls?: Array
}
+/**
+ * Request body for creating a new Hub profile.
+ */
export type CreateHubProfileRequest = {
/**
* ID of the workspace to create the hub profile for. The authenticated user must belong to this workspace.
@@ -446,6 +491,9 @@ export type CreateHubProfileRequest = {
website_urls?: Array
}
+/**
+ * Full public profile for a Hub creator.
+ */
export type HubProfile = {
username: string
display_name?: string
@@ -460,17 +508,39 @@ export type HubProfile = {
website_urls?: Array
}
+/**
+ * Response after importing published workflow assets.
+ */
export type ImportPublishedAssetsResponse = {
assets: Array
}
+/**
+ * Request body for importing assets from a published workflow.
+ */
export type ImportPublishedAssetsRequest = {
/**
* IDs of published assets (inputs and models) to import.
*/
published_asset_ids: Array
+ /**
+ * Optional. Share ID of the published workflow these assets belong to.
+ * When provided (non-null, non-empty): all published_asset_ids must
+ * belong to this share's workflow version; returns
+ * 400/CodeInvalidAssets if the share is not found or any asset does
+ * not belong to it.
+ * When omitted, null, or empty string: no share-scoped validation is
+ * performed and the assets are validated only against global rules
+ * (legacy behaviour, preserved for clients that have not yet adopted
+ * share_id).
+ *
+ */
+ share_id?: string | null
}
+/**
+ * Full detail of a publicly published workflow on the Hub.
+ */
export type PublishedWorkflowDetail = {
share_id: string
workflow_id: string
@@ -492,16 +562,25 @@ export type PublishedWorkflowDetail = {
assets: Array
}
+/**
+ * Response containing assets associated with a workflow.
+ */
export type WorkflowApiAssetsResponse = {
assets: Array
}
+/**
+ * Request body for querying assets associated with a workflow.
+ */
export type WorkflowApiAssetsRequest = {
workflow_api_json: {
[key: string]: unknown
}
}
+/**
+ * Request body for publishing workflow assets to the Hub.
+ */
export type PublishWorkflowAssetsRequest = {
/**
* IDs of assets (inputs and models) to snapshot.
@@ -509,6 +588,9 @@ export type PublishWorkflowAssetsRequest = {
asset_ids: Array
}
+/**
+ * Publishing metadata for a workflow shared to the Hub.
+ */
export type WorkflowPublishInfo = {
workflow_id: string
share_id: string
@@ -520,6 +602,9 @@ export type WorkflowPublishInfo = {
assets: Array
}
+/**
+ * Request body for forking an existing workflow into the user's account.
+ */
export type ForkWorkflowRequest = {
/**
* Version number to fork from
@@ -531,6 +616,9 @@ export type ForkWorkflowRequest = {
name?: string
}
+/**
+ * Full workflow version including the serialized workflow JSON.
+ */
export type WorkflowVersionContentResponse = {
id: string
version: number
@@ -542,6 +630,9 @@ export type WorkflowVersionContentResponse = {
dependency_asset_ids?: Array
}
+/**
+ * Metadata for a single workflow version.
+ */
export type WorkflowVersionResponse = {
id: string
version: number
@@ -550,6 +641,9 @@ export type WorkflowVersionResponse = {
created_at: string
}
+/**
+ * Request body for creating a new version of a saved workflow.
+ */
export type CreateWorkflowVersionRequest = {
/**
* The version number this change is based on (for optimistic concurrency)
@@ -563,11 +657,17 @@ export type CreateWorkflowVersionRequest = {
}
}
+/**
+ * Paginated list of saved workflows.
+ */
export type WorkflowListResponse = {
data: Array
pagination: PaginationInfo
}
+/**
+ * Offset/limit-based pagination metadata included in list responses.
+ */
export type PaginationInfo = {
/**
* Current offset (0-based)
@@ -587,11 +687,17 @@ export type PaginationInfo = {
has_more: boolean
}
+/**
+ * Reference to the parent workflow from which this workflow was forked.
+ */
export type WorkflowForkedFrom = {
workflow_id?: string
workflow_version_id?: string
}
+/**
+ * Full workflow entity including metadata and version history.
+ */
export type WorkflowResponse = {
id: string
name?: string
@@ -604,6 +710,9 @@ export type WorkflowResponse = {
updated_at: string
}
+/**
+ * Request body for updating an existing saved workflow.
+ */
export type UpdateWorkflowRequest = {
/**
* New display name
@@ -619,6 +728,9 @@ export type UpdateWorkflowRequest = {
default_view?: 'workflow' | 'app'
}
+/**
+ * Request body for creating a new saved workflow.
+ */
export type CreateWorkflowRequest = {
/**
* Display name for the workflow
@@ -648,6 +760,9 @@ export type CreateWorkflowRequest = {
forked_from_workflow_version_id?: string
}
+/**
+ * Response after recording partner usage data.
+ */
export type PartnerUsageResponse = {
/**
* Result status (e.g., "ok")
@@ -655,6 +770,9 @@ export type PartnerUsageResponse = {
status: string
}
+/**
+ * Request body for reporting partner resource usage (admin endpoint).
+ */
export type PartnerUsageRequest = {
/**
* The workspace ID to bill usage against
@@ -684,6 +802,9 @@ export type PartnerUsageRequest = {
}
}
+/**
+ * Status of an asynchronous billing operation.
+ */
export type BillingOpStatusResponse = {
/**
* Unique identifier for the billing operation
@@ -707,6 +828,9 @@ export type BillingOpStatusResponse = {
completed_at?: string
}
+/**
+ * Response after successfully purchasing a credit top-up.
+ */
export type CreateTopupResponse = {
/**
* Billing operation ID to poll for status via GET /api/billing/ops/{id}
@@ -726,6 +850,9 @@ export type CreateTopupResponse = {
amount_cents: number
}
+/**
+ * Request body for purchasing a one-time credit top-up.
+ */
export type CreateTopupRequest = {
/**
* Amount to charge and grant as credits (in cents). Minimum $5.00.
@@ -739,6 +866,9 @@ export type CreateTopupRequest = {
idempotency_key?: string
}
+/**
+ * Response containing a redirect URL to the payment portal.
+ */
export type PaymentPortalResponse = {
/**
* Stripe Billing Portal URL
@@ -746,6 +876,9 @@ export type PaymentPortalResponse = {
url: string
}
+/**
+ * Request body for generating a payment portal session URL.
+ */
export type PaymentPortalRequest = {
/**
* URL to redirect after the user exits the portal
@@ -753,6 +886,9 @@ export type PaymentPortalRequest = {
return_url?: string
}
+/**
+ * Response after successfully resubscribing to a billing plan.
+ */
export type ResubscribeResponse = {
/**
* Billing operation ID to poll for status via GET /api/billing/ops/{id}
@@ -768,6 +904,9 @@ export type ResubscribeResponse = {
message?: string
}
+/**
+ * Request body for reactivating a previously cancelled subscription.
+ */
export type ResubscribeRequest = {
/**
* Client-provided key to prevent duplicate operations.
@@ -777,6 +916,9 @@ export type ResubscribeRequest = {
idempotency_key?: string
}
+/**
+ * Response after successfully cancelling a subscription.
+ */
export type CancelSubscriptionResponse = {
/**
* Billing operation ID to poll for status via GET /api/billing/ops/{id}
@@ -788,6 +930,9 @@ export type CancelSubscriptionResponse = {
cancel_at: string
}
+/**
+ * Request body for cancelling the current subscription.
+ */
export type CancelSubscriptionRequest = {
/**
* Client-provided key to prevent duplicate operations.
@@ -797,6 +942,9 @@ export type CancelSubscriptionRequest = {
idempotency_key?: string
}
+/**
+ * Response after successfully subscribing to a billing plan.
+ */
export type SubscribeResponse = {
/**
* Billing operation ID to poll for status via GET /api/billing/ops/{id}
@@ -820,6 +968,9 @@ export type SubscribeResponse = {
payment_method_url?: string
}
+/**
+ * Request body for subscribing a workspace to a billing plan.
+ */
export type SubscribeRequest = {
/**
* Target plan slug to subscribe to
@@ -907,6 +1058,9 @@ export type SubscriptionTier =
| 'PRO'
| 'FOUNDERS_EDITION'
+/**
+ * Itemized cost preview for a pending subscription change.
+ */
export type PreviewSubscribeResponse = {
/**
* Whether this subscription change is allowed
@@ -952,6 +1106,9 @@ export type PreviewSubscribeResponse = {
new_plan: PreviewPlanInfo
}
+/**
+ * Request body for previewing the cost of a plan subscription change.
+ */
export type PreviewSubscribeRequest = {
/**
* Target plan slug to preview subscribing to
@@ -959,6 +1116,9 @@ export type PreviewSubscribeRequest = {
plan_slug: string
}
+/**
+ * List of available billing plans for subscription.
+ */
export type BillingPlansResponse = {
/**
* Current plan slug if subscribed
@@ -977,6 +1137,9 @@ export type PlanAvailabilityReason =
| 'requires_personal'
| 'exceeds_max_seats'
+/**
+ * Availability and eligibility information for a billing plan.
+ */
export type PlanAvailability = {
/**
* Whether the workspace can subscribe to this plan
@@ -985,6 +1148,9 @@ export type PlanAvailability = {
reason?: PlanAvailabilityReason
}
+/**
+ * Billing plan details including pricing, limits, and features.
+ */
export type Plan = {
/**
* Plan identifier (e.g., "pro-monthly", "team-standard-annual")
@@ -1008,10 +1174,16 @@ export type Plan = {
seat_summary: PlanSeatSummary
}
+/**
+ * List of user secrets with metadata only.
+ */
export type SecretListResponse = {
data: Array
}
+/**
+ * User secret metadata (the secret value itself is never returned after creation).
+ */
export type SecretResponse = {
/**
* Unique identifier for the secret
@@ -1039,6 +1211,9 @@ export type SecretResponse = {
updated_at: string
}
+/**
+ * Request body for updating an existing user secret.
+ */
export type UpdateSecretRequest = {
/**
* New name for the secret
@@ -1050,6 +1225,9 @@ export type UpdateSecretRequest = {
secret_value?: string
}
+/**
+ * Request body for creating a new user secret.
+ */
export type CreateSecretRequest = {
/**
* User-provided label for the secret
@@ -1065,6 +1243,9 @@ export type CreateSecretRequest = {
secret_value: string
}
+/**
+ * Paginated list of billing events for a workspace.
+ */
export type BillingEventsResponse = {
/**
* Total number of events
@@ -1085,6 +1266,9 @@ export type BillingEventsResponse = {
totalPages: number
}
+/**
+ * A single billing event such as a charge, credit, or adjustment.
+ */
export type BillingEvent = {
/**
* Type of billing event (e.g., subscription.created, payment.succeeded)
@@ -1106,6 +1290,9 @@ export type BillingEvent = {
createdAt: string
}
+/**
+ * Current credit balance and usage details for a workspace.
+ */
export type BillingBalanceResponse = {
/**
* The total remaining balance in microamount (1/1,000,000 of the currency unit)
@@ -1133,6 +1320,9 @@ export type BillingBalanceResponse = {
currency: string
}
+/**
+ * Current billing and subscription status for a workspace.
+ */
export type BillingStatusResponse = {
/**
* Whether the workspace has an active subscription
@@ -1173,6 +1363,9 @@ export type BillingStatus =
| 'payment_failed'
| 'inactive'
+/**
+ * A single JSON Web Key entry within a JWKS response.
+ */
export type JwkKey = {
kty: string
crv: string
@@ -1189,10 +1382,59 @@ export type JwkKey = {
y: string
}
+/**
+ * JSON Web Key Set containing the public keys used to verify Cloud JWTs.
+ */
export type JwksResponse = {
keys: Array
}
+/**
+ * Response after synchronizing an API key into the local database.
+ */
+export type SyncApiKeyResponse = {
+ /**
+ * `revoked` — matching row found, was active, now revoked.
+ * `already_revoked` — matching row found, already revoked.
+ * `no_op` — no row matches the supplied hash.
+ *
+ */
+ result: 'revoked' | 'already_revoked' | 'no_op'
+}
+
+/**
+ * Request body for synchronizing an API key from the external registry.
+ */
+export type SyncApiKeyRequest = {
+ /**
+ * Lifecycle event type. Only `delete` is supported in Phase 1.
+ */
+ event: 'delete'
+ /**
+ * SHA-256 hex digest of the plaintext API key (64 hex characters).
+ * Case-insensitive: the server lowercases the value before lookup, so
+ * producers may emit lowercase or uppercase hex. The lowercase form
+ * is recommended for consistency with the rest of the codebase, which
+ * computes hashes via `hex.EncodeToString`.
+ *
+ */
+ key_hash: string
+ /**
+ * Firebase UID of the key's owner according to comfy-api. Required on
+ * the request so cloud can detect drift between the two systems, but
+ * **advisory only**: `key_hash` is the sole authoritative identifier
+ * for the revocation. A mismatch against cloud's stored `user_id` is
+ * logged and emits `admin.api_key_sync.delete.customer_mismatch`, but
+ * does not change the outcome — the matching row is still revoked so
+ * a subsequent sync call can repair drift.
+ *
+ */
+ customer_id: string
+}
+
+/**
+ * Response confirming the validity and scope of a workspace API key.
+ */
export type VerifyApiKeyResponse = {
/**
* Firebase UID of the key creator
@@ -1242,6 +1484,9 @@ export type VerifyApiKeyResponse = {
permissions: Array
}
+/**
+ * Request body for verifying a workspace API key (admin endpoint).
+ */
export type VerifyApiKeyRequest = {
/**
* The full plaintext API key to verify
@@ -1249,10 +1494,26 @@ export type VerifyApiKeyRequest = {
api_key: string
}
+/**
+ * Response after bulk-revoking API keys for a workspace member.
+ */
+export type BulkRevokeApiKeysResponse = {
+ /**
+ * Number of API keys that were revoked
+ */
+ revoked_count: number
+}
+
+/**
+ * List of API keys associated with the current workspace.
+ */
export type ListWorkspaceApiKeysResponse = {
api_keys: Array
}
+/**
+ * Metadata for a workspace-scoped API key (secret is never returned).
+ */
export type WorkspaceApiKeyInfo = {
/**
* API key ID
@@ -1292,6 +1553,9 @@ export type WorkspaceApiKeyInfo = {
created_at: string
}
+/**
+ * Response containing the newly created workspace API key.
+ */
export type CreateWorkspaceApiKeyResponse = {
/**
* API key ID
@@ -1319,6 +1583,9 @@ export type CreateWorkspaceApiKeyResponse = {
created_at: string
}
+/**
+ * Request body for creating a new workspace-scoped API key.
+ */
export type CreateWorkspaceApiKeyRequest = {
/**
* User-provided label for the key
@@ -1330,6 +1597,9 @@ export type CreateWorkspaceApiKeyRequest = {
expires_at?: string
}
+/**
+ * Response returned after successfully accepting a workspace invitation.
+ */
export type AcceptInviteResponse = {
/**
* ID of the workspace joined
@@ -1341,6 +1611,9 @@ export type AcceptInviteResponse = {
workspace_name: string
}
+/**
+ * Request body for inviting a user to a workspace.
+ */
export type CreateInviteRequest = {
/**
* Email address to invite
@@ -1348,10 +1621,16 @@ export type CreateInviteRequest = {
email: string
}
+/**
+ * List of pending invitations for the current workspace.
+ */
export type ListInvitesResponse = {
invites: Array
}
+/**
+ * An outstanding workspace invitation that has not yet been accepted.
+ */
export type PendingInvite = {
/**
* Invite ID
@@ -1375,11 +1654,17 @@ export type PendingInvite = {
expires_at: string
}
+/**
+ * List of members in the current workspace.
+ */
export type ListMembersResponse = {
members: Array
pagination: PaginationInfo
}
+/**
+ * Workspace member with profile and role information.
+ */
export type Member = {
/**
* User ID
@@ -1403,6 +1688,9 @@ export type Member = {
joined_at: string
}
+/**
+ * Request body for updating an existing workspace's settings.
+ */
export type UpdateWorkspaceRequest = {
/**
* New display name for the workspace
@@ -1410,6 +1698,9 @@ export type UpdateWorkspaceRequest = {
name?: string
}
+/**
+ * Request body for creating a new workspace.
+ */
export type CreateWorkspaceRequest = {
/**
* Display name for the workspace
@@ -1417,6 +1708,9 @@ export type CreateWorkspaceRequest = {
name: string
}
+/**
+ * Workspace entity annotated with the requesting user's role.
+ */
export type WorkspaceWithRole = {
id: string
name: string
@@ -1433,10 +1727,16 @@ export type WorkspaceWithRole = {
subscription_tier?: SubscriptionTier
}
+/**
+ * Paginated list of workspaces the authenticated user belongs to.
+ */
export type ListWorkspacesResponse = {
workspaces: Array
}
+/**
+ * Full workspace entity with configuration and ownership details.
+ */
export type Workspace = {
id: string
name: string
@@ -1444,12 +1744,18 @@ export type Workspace = {
created_at: string
}
+/**
+ * Abbreviated workspace metadata used in list responses.
+ */
export type WorkspaceSummary = {
id: string
name: string
type: 'personal' | 'team'
}
+/**
+ * Response containing the issued Cloud JWT and its expiry.
+ */
export type ExchangeTokenResponse = {
/**
* Cloud JWT token
@@ -1470,6 +1776,12 @@ export type ExchangeTokenResponse = {
permissions: Array
}
+/**
+ * Optional request body for the token exchange endpoint. The Firebase JWT
+ * being exchanged is supplied via the `Authorization: Bearer` header; this
+ * body only carries workspace-selection input.
+ *
+ */
export type ExchangeTokenRequest = {
/**
* Workspace ID to get token for. Defaults to personal workspace if omitted.
@@ -1561,6 +1873,9 @@ export type TaskEntry = {
completed_at?: string
}
+/**
+ * Paginated list of background tasks for the authenticated user.
+ */
export type TasksListResponse = {
/**
* Array of tasks ordered by create_time
@@ -1569,6 +1884,9 @@ export type TasksListResponse = {
pagination: PaginationInfo
}
+/**
+ * Details of a pending or completed user data deletion request.
+ */
export type DeletionRequest = {
/**
* Unique identifier for the deletion request
@@ -1588,6 +1906,9 @@ export type DeletionRequest = {
deletion_status: Array
}
+/**
+ * Current status of a user data deletion request.
+ */
export type DeletionStatus = {
/**
* The name of the deletion status
@@ -1650,7 +1971,13 @@ export type JobDetailResponse = {
*/
status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'cancelled'
/**
- * Full ComfyUI workflow (10-100KB, omitted if not available)
+ * Full ComfyUI workflow (10-100KB, omitted if not available).
+ *
+ * Sensitive credentials are redacted before the response is returned:
+ * `extra_data.api_key_comfy_org`, when present, is replaced with the
+ * literal string `"[REDACTED]"`. The field is preserved (not removed)
+ * so existence checks still pass, but the value is not usable.
+ *
*/
workflow?: {
[key: string]: unknown
@@ -1701,6 +2028,19 @@ export type JobDetailResponse = {
}
}
+/**
+ * Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.
+ */
+export type JobCancelResponse = {
+ /**
+ * True when a cancel event was successfully dispatched by this call.
+ * False when the job was already in a terminal or cancelling state,
+ * in which case the call is a no-op (still 200 — idempotent).
+ *
+ */
+ cancelled: boolean
+}
+
/**
* Lightweight job data for list views (workflow and full outputs excluded)
*/
@@ -1745,6 +2085,9 @@ export type JobEntry = {
execution_end_time?: number
}
+/**
+ * Paginated list of jobs for the authenticated user.
+ */
export type JobsListResponse = {
/**
* Array of jobs ordered by specified sort field
@@ -1753,6 +2096,9 @@ export type JobsListResponse = {
pagination: PaginationInfo
}
+/**
+ * Response after adding, updating, or removing tags on an asset.
+ */
export type TagsModificationResponse = {
/**
* Tags that were successfully added (for add operation)
@@ -1776,6 +2122,9 @@ export type TagsModificationResponse = {
total_tags: Array
}
+/**
+ * Details of a single validation error encountered during asset operations.
+ */
export type ValidationError = {
/**
* Machine-readable error code
@@ -1791,6 +2140,9 @@ export type ValidationError = {
field: string
}
+/**
+ * Result of validating a set of asset operations.
+ */
export type ValidationResult = {
/**
* Overall validation status (true if all checks passed)
@@ -1806,6 +2158,9 @@ export type ValidationResult = {
warnings?: Array
}
+/**
+ * Acknowledgement of an async asset download task; clients poll GET /api/tasks/{task_id} for status.
+ */
export type AssetDownloadResponse = {
/**
* Task ID for tracking download progress via GET /api/tasks/{task_id}
@@ -1821,6 +2176,9 @@ export type AssetDownloadResponse = {
message?: string
}
+/**
+ * Metadata for a remotely hosted asset resolved by URL.
+ */
export type AssetMetadataResponse = {
/**
* Size of the asset in bytes (-1 if unknown)
@@ -1852,6 +2210,9 @@ export type AssetMetadataResponse = {
validation?: ValidationResult
}
+/**
+ * Histogram of tag counts used for refining asset search results.
+ */
export type AssetTagHistogramResponse = {
/**
* Map of tag names to their occurrence counts on matching assets
@@ -1861,6 +2222,9 @@ export type AssetTagHistogramResponse = {
}
}
+/**
+ * Paginated list of available asset tags.
+ */
export type ListTagsResponse = {
/**
* List of tags
@@ -1876,6 +2240,9 @@ export type ListTagsResponse = {
has_more: boolean
}
+/**
+ * Metadata for a single tag that can be applied to assets.
+ */
export type TagInfo = {
/**
* Tag name
@@ -1887,6 +2254,9 @@ export type TagInfo = {
count: number
}
+/**
+ * Paginated list of assets belonging to the authenticated user.
+ */
export type ListAssetsResponse = {
/**
* List of assets matching the query
@@ -1902,6 +2272,9 @@ export type ListAssetsResponse = {
has_more: boolean
}
+/**
+ * Represents a user-owned asset (image, video, or other generated output).
+ */
export type Asset = {
/**
* Unique identifier for the asset
@@ -1948,9 +2321,15 @@ export type Asset = {
*/
preview_id?: string | null
/**
- * ID of the job/prompt that created this asset, if available
+ * Deprecated: use job_id instead. ID of the prompt that created this asset, if available
+ *
+ * @deprecated
*/
prompt_id?: string | null
+ /**
+ * ID of the job that created this asset, if available
+ */
+ job_id?: string | null
/**
* Timestamp when the asset was created
*/
@@ -1969,6 +2348,9 @@ export type Asset = {
is_immutable?: boolean
}
+/**
+ * Response returned when an existing asset is successfully updated.
+ */
export type AssetUpdated = {
/**
* Asset ID
@@ -2002,6 +2384,9 @@ export type AssetUpdated = {
updated_at: string
}
+/**
+ * Response returned when a new asset is successfully created.
+ */
export type AssetCreated = Asset & {
/**
* Whether this was a new asset creation (true) or returned existing (false)
@@ -2009,6 +2394,9 @@ export type AssetCreated = Asset & {
created_new: boolean
}
+/**
+ * Response after updating the review status of a Hub workflow.
+ */
export type SetReviewStatusResponse = {
/**
* The share IDs that were submitted for review
@@ -2020,6 +2408,9 @@ export type SetReviewStatusResponse = {
status: 'approved' | 'rejected'
}
+/**
+ * Request body for setting the review status of a Hub workflow.
+ */
export type SetReviewStatusRequest = {
/**
* The share IDs of the hub workflows to review
@@ -2233,6 +2624,9 @@ export type PromptErrorResponse = {
[key: string]: unknown
}
+/**
+ * Individual file entry within a full user data response.
+ */
export type GetUserDataResponseFullFile = {
/**
* File name or path relative to the user directory.
@@ -2248,8 +2642,14 @@ export type GetUserDataResponseFullFile = {
modified?: number
}
+/**
+ * List of user data file entries (each with path, size, and modification time) returned when full_info=true.
+ */
export type GetUserDataResponseFull = Array
+/**
+ * User data listing entry with file metadata (path, size, modification time).
+ */
export type UserDataResponseFull = {
path?: string
size?: number
@@ -2313,6 +2713,9 @@ export type JobStatusResponse = {
error_message?: string | null
}
+/**
+ * Response after a queue management action (delete or clear).
+ */
export type QueueManageResponse = {
/**
* Array of job IDs that were successfully cancelled
@@ -2536,6 +2939,9 @@ export type GlobalSubgraphInfo = {
data?: string
}
+/**
+ * Metadata describing a single ComfyUI node type and its inputs/outputs.
+ */
export type NodeInfo = {
/**
* Input specifications for the node
@@ -2603,6 +3009,9 @@ export type NodeInfo = {
api_node?: boolean
}
+/**
+ * Metadata about the currently running and queued prompts.
+ */
export type PromptInfo = {
exec_info?: {
/**
@@ -2612,6 +3021,9 @@ export type PromptInfo = {
}
}
+/**
+ * Response containing a signed download URL for an exported asset archive.
+ */
export type ExportDownloadUrlResponse = {
/**
* Signed URL for downloading the export ZIP file
@@ -2623,15 +3035,24 @@ export type ExportDownloadUrlResponse = {
expires_at?: string
}
+/**
+ * Error shape returned when request binding or validation fails before the handler runs.
+ */
export type BindingErrorResponse = {
message: string
}
+/**
+ * Standard error response with a machine-readable code and human-readable message.
+ */
export type ErrorResponse = {
code: string
message: string
}
+/**
+ * Response returned after successfully queuing a workflow prompt.
+ */
export type PromptResponse = {
/**
* Unique identifier for the prompt execution
@@ -2649,6 +3070,9 @@ export type PromptResponse = {
}
}
+/**
+ * Request body for submitting a ComfyUI workflow prompt for execution.
+ */
export type PromptRequest = {
/**
* The workflow graph to execute
@@ -2684,6 +3108,9 @@ export type PromptRequest = {
workflow_version_id?: string
}
+/**
+ * Paginated list of assets belonging to the authenticated user.
+ */
export type ListAssetsResponseWritable = {
/**
* List of assets matching the query
@@ -2699,6 +3126,9 @@ export type ListAssetsResponseWritable = {
has_more: boolean
}
+/**
+ * Represents a user-owned asset (image, video, or other generated output).
+ */
export type AssetWritable = {
/**
* Unique identifier for the asset
@@ -2739,9 +3169,15 @@ export type AssetWritable = {
*/
preview_id?: string | null
/**
- * ID of the job/prompt that created this asset, if available
+ * Deprecated: use job_id instead. ID of the prompt that created this asset, if available
+ *
+ * @deprecated
*/
prompt_id?: string | null
+ /**
+ * ID of the job that created this asset, if available
+ */
+ job_id?: string | null
/**
* Timestamp when the asset was created
*/
@@ -2760,6 +3196,9 @@ export type AssetWritable = {
is_immutable?: boolean
}
+/**
+ * Response returned when a new asset is successfully created.
+ */
export type AssetCreatedWritable = AssetWritable & {
/**
* Whether this was a new asset creation (true) or returned existing (false)
@@ -3112,6 +3551,20 @@ export type GetModelPreviewResponses = {
export type GetModelPreviewResponse =
GetModelPreviewResponses[keyof GetModelPreviewResponses]
+export type GetLegacyHistoryData = {
+ body?: never
+ path?: never
+ query?: never
+ url: '/api/history'
+}
+
+export type GetLegacyHistoryErrors = {
+ /**
+ * Not Found — use /api/history_v2 instead
+ */
+ 404: unknown
+}
+
export type ManageHistoryData = {
body: HistoryManageRequest
path?: never
@@ -3322,6 +3775,48 @@ export type GetJobDetailResponses = {
export type GetJobDetailResponse =
GetJobDetailResponses[keyof GetJobDetailResponses]
+export type CancelJobData = {
+ body?: never
+ path: {
+ /**
+ * Job identifier (UUID)
+ */
+ job_id: string
+ }
+ query?: never
+ url: '/api/jobs/{job_id}/cancel'
+}
+
+export type CancelJobErrors = {
+ /**
+ * Bad Request - job_id is not a valid UUID (emitted by request validation before the handler runs)
+ */
+ 400: BindingErrorResponse
+ /**
+ * Unauthorized - Authentication required
+ */
+ 401: ErrorResponse
+ /**
+ * Job not found for this user
+ */
+ 404: ErrorResponse
+ /**
+ * Internal server error - cancellation failed
+ */
+ 500: ErrorResponse
+}
+
+export type CancelJobError = CancelJobErrors[keyof CancelJobErrors]
+
+export type CancelJobResponses = {
+ /**
+ * Success - Cancel request accepted (or job was already terminal)
+ */
+ 200: JobCancelResponse
+}
+
+export type CancelJobResponse = CancelJobResponses[keyof CancelJobResponses]
+
export type ViewFileData = {
body?: never
path?: never
@@ -3753,7 +4248,7 @@ export type GetRemoteAssetMetadataResponse =
export type CreateAssetDownloadData = {
body: {
/**
- * URL of the file to download (must be from huggingface.co or civitai.com)
+ * URL of the file to download (must be from huggingface.co, civitai.com, or civitai.red)
*/
source_url: string
/**
@@ -3825,16 +4320,16 @@ export type CreateAssetExportData = {
/**
* Strategy for naming files in the ZIP:
* - group_by_job_id: Group assets by job ID as a parent directory (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4/ComfyUI_00001_.png")
- * - group_by_job_time: Group assets by job execution time as parent directories
* - preserve: Use original asset names, skip duplicates (first one wins)
* - asset_id: Use the asset ID as the filename (e.g., "833a1b5c-beab-436a-ae8e-f07e7cd7b2c4.png")
+ * - group_by_job_time: Group by job creation timestamp (e.g., "2026-03-26T16-13-00/ComfyUI_00001_.png")
*
*/
naming_strategy?:
| 'group_by_job_id'
- | 'group_by_job_time'
| 'preserve'
| 'asset_id'
+ | 'group_by_job_time'
/**
* Optional per-job asset name filters. When provided for a job ID,
* only assets whose name matches one of the specified names are included.
@@ -3943,6 +4438,10 @@ export type DeleteAssetErrors = {
* Asset not found
*/
404: ErrorResponse
+ /**
+ * Asset cannot be deleted because it is referenced by another resource (e.g., workflow version)
+ */
+ 409: ErrorResponse
/**
* Internal server error
*/
@@ -4441,10 +4940,6 @@ export type ImportPublishedAssetsErrors = {
* Unauthorized
*/
401: ErrorResponse
- /**
- * Not found
- */
- 404: ErrorResponse
/**
* Internal server error
*/
@@ -5028,6 +5523,9 @@ export type GetUserdataResponse =
export type GetUserdataFilePublishData = {
body?: never
path: {
+ /**
+ * The workflow file path within the user's data directory (URL encoded if necessary).
+ */
file: string
}
query?: never
@@ -5065,6 +5563,9 @@ export type GetUserdataFilePublishResponse =
export type PostUserdataFilePublishData = {
body: PublishWorkflowAssetsRequest
path: {
+ /**
+ * The workflow file path within the user's data directory (URL encoded if necessary).
+ */
file: string
}
query?: never
@@ -6256,6 +6757,50 @@ export type RevokeWorkspaceApiKeyResponses = {
export type RevokeWorkspaceApiKeyResponse =
RevokeWorkspaceApiKeyResponses[keyof RevokeWorkspaceApiKeyResponses]
+export type BulkRevokeWorkspaceMemberApiKeysData = {
+ body?: never
+ path: {
+ /**
+ * Firebase UID of the member whose keys to revoke (must be non-empty)
+ */
+ user_id: string
+ }
+ query?: never
+ url: '/api/workspace/members/{user_id}/api-keys'
+}
+
+export type BulkRevokeWorkspaceMemberApiKeysErrors = {
+ /**
+ * Unauthorized
+ */
+ 401: ErrorResponse
+ /**
+ * Not authorized (must be workspace owner)
+ */
+ 403: ErrorResponse
+ /**
+ * Validation error (e.g. empty user_id)
+ */
+ 422: ErrorResponse
+ /**
+ * Internal server error
+ */
+ 500: ErrorResponse
+}
+
+export type BulkRevokeWorkspaceMemberApiKeysError =
+ BulkRevokeWorkspaceMemberApiKeysErrors[keyof BulkRevokeWorkspaceMemberApiKeysErrors]
+
+export type BulkRevokeWorkspaceMemberApiKeysResponses = {
+ /**
+ * Keys revoked successfully
+ */
+ 200: BulkRevokeApiKeysResponse
+}
+
+export type BulkRevokeWorkspaceMemberApiKeysResponse =
+ BulkRevokeWorkspaceMemberApiKeysResponses[keyof BulkRevokeWorkspaceMemberApiKeysResponses]
+
export type VerifyWorkspaceApiKeyData = {
body: VerifyApiKeyRequest
path?: never
@@ -6359,6 +6904,9 @@ export type SetReviewStatusResponse2 =
export type UpdateHubWorkflowData = {
body: UpdateHubWorkflowRequest
path: {
+ /**
+ * The share ID of the hub workflow to update.
+ */
share_id: string
}
query?: never
@@ -6592,6 +7140,39 @@ export type UpdateSubscriptionCacheResponses = {
export type UpdateSubscriptionCacheResponse =
UpdateSubscriptionCacheResponses[keyof UpdateSubscriptionCacheResponses]
+export type SyncApiKeyData = {
+ body: SyncApiKeyRequest
+ path?: never
+ query?: never
+ url: '/admin/api/keys/sync'
+}
+
+export type SyncApiKeyErrors = {
+ /**
+ * Malformed request or unsupported event
+ */
+ 400: ErrorResponse
+ /**
+ * Missing or invalid admin secret
+ */
+ 401: ErrorResponse
+ /**
+ * Internal error
+ */
+ 500: ErrorResponse
+}
+
+export type SyncApiKeyError = SyncApiKeyErrors[keyof SyncApiKeyErrors]
+
+export type SyncApiKeyResponses = {
+ /**
+ * Sync processed — see `result` field
+ */
+ 200: SyncApiKeyResponse
+}
+
+export type SyncApiKeyResponse2 = SyncApiKeyResponses[keyof SyncApiKeyResponses]
+
export type GetJobStatusData = {
body?: never
path: {
@@ -7209,6 +7790,9 @@ export type CreateWorkflowResponse =
export type DeleteWorkflowData = {
body?: never
path: {
+ /**
+ * The UUID of the workflow to delete.
+ */
workflow_id: string
}
query?: never
@@ -7246,6 +7830,9 @@ export type DeleteWorkflowResponse =
export type GetWorkflowData = {
body?: never
path: {
+ /**
+ * The UUID of the workflow.
+ */
workflow_id: string
}
query?: never
@@ -7286,6 +7873,9 @@ export type GetWorkflowResponse =
export type UpdateWorkflowData = {
body: UpdateWorkflowRequest
path: {
+ /**
+ * The UUID of the workflow to update.
+ */
workflow_id: string
}
query?: never
@@ -7327,6 +7917,9 @@ export type UpdateWorkflowResponse =
export type CreateWorkflowVersionData = {
body: CreateWorkflowVersionRequest
path: {
+ /**
+ * The UUID of the workflow to create a new version for.
+ */
workflow_id: string
}
query?: never
@@ -7376,6 +7969,9 @@ export type CreateWorkflowVersionResponse =
export type GetWorkflowContentData = {
body?: never
path: {
+ /**
+ * The UUID of the workflow whose content should be retrieved.
+ */
workflow_id: string
}
query?: never
@@ -7417,6 +8013,9 @@ export type GetWorkflowContentResponse =
export type ForkWorkflowData = {
body: ForkWorkflowRequest
path: {
+ /**
+ * The UUID of the source workflow to fork from.
+ */
workflow_id: string
}
query?: never
@@ -7576,6 +8175,9 @@ export type CheckHubUsernameResponse =
export type GetHubProfileByUsernameData = {
body?: never
path: {
+ /**
+ * The hub profile username.
+ */
username: string
}
query?: never
@@ -7609,6 +8211,9 @@ export type GetHubProfileByUsernameResponse =
export type UpdateHubProfileData = {
body: UpdateHubProfileRequest
path: {
+ /**
+ * The hub profile username to update.
+ */
username: string
}
query?: never
@@ -7853,6 +8458,9 @@ export type ListHubWorkflowIndexResponse =
export type DeleteHubWorkflowData = {
body?: never
path: {
+ /**
+ * The share ID of the hub workflow to unpublish.
+ */
share_id: string
}
query?: never
@@ -7890,6 +8498,9 @@ export type DeleteHubWorkflowResponse =
export type GetHubWorkflowData = {
body?: never
path: {
+ /**
+ * The share ID of the hub workflow.
+ */
share_id: string
}
query?: never
@@ -7927,6 +8538,9 @@ export type GetHubWorkflowResponse =
export type GetPublishedWorkflowData = {
body?: never
path: {
+ /**
+ * The share ID of the published workflow.
+ */
share_id: string
}
query?: never
@@ -8263,6 +8877,9 @@ export type GetWebsocketErrors = {
export type GetTemplateProxyData = {
body?: never
path: {
+ /**
+ * Template subpath within the versioned GCS bucket.
+ */
path: string
}
query?: never
@@ -8343,6 +8960,9 @@ export type GetMonitoringTasksResponses = {
export type DeleteMonitoringTasksSubpathData = {
body?: never
path: {
+ /**
+ * Asynqmon deletion subpath (e.g. delete task).
+ */
path: string
}
query?: never
@@ -8370,6 +8990,9 @@ export type DeleteMonitoringTasksSubpathResponses = {
export type GetMonitoringTasksSubpathData = {
body?: never
path: {
+ /**
+ * Asynqmon UI subpath (HTML page, SPA XHR, or static asset).
+ */
path: string
}
query?: never
@@ -8397,6 +9020,9 @@ export type GetMonitoringTasksSubpathResponses = {
export type PostMonitoringTasksSubpathData = {
body?: never
path: {
+ /**
+ * Asynqmon action subpath (e.g. retry, archive).
+ */
path: string
}
query?: never
@@ -8424,6 +9050,9 @@ export type PostMonitoringTasksSubpathResponses = {
export type GetPprofData = {
body?: never
path: {
+ /**
+ * pprof endpoint name (e.g. heap, goroutine, allocs, block, mutex, threadcreate).
+ */
path: string
}
query?: never
@@ -8482,6 +9111,9 @@ export type PostPprofSymbolResponses = {
export type GetStaticExtensionsData = {
body?: never
path: {
+ /**
+ * Extension file path relative to /static/extensions on disk.
+ */
path: string
}
query?: never
@@ -8505,6 +9137,9 @@ export type GetStaticExtensionsResponses = {
export type GetCustomNodeProxyData = {
body?: never
path: {
+ /**
+ * Custom node HTTP endpoint path being proxied to the CPU-backed worker.
+ */
path: string
}
query?: never
@@ -8532,6 +9167,9 @@ export type GetCustomNodeProxyResponses = {
export type PostCustomNodeProxyData = {
body?: never
path: {
+ /**
+ * Custom node HTTP endpoint path being proxied to the CPU-backed worker.
+ */
path: string
}
query?: never
@@ -8555,3 +9193,159 @@ export type PostCustomNodeProxyResponses = {
*/
200: unknown
}
+
+export type GetLegacyPromptByIdData = {
+ body?: never
+ path: {
+ prompt_id: string
+ }
+ query?: never
+ url: '/api/prompt/{prompt_id}'
+}
+
+export type GetLegacyPromptByIdErrors = {
+ /**
+ * Not Found — use /api/jobs/{prompt_id} instead
+ */
+ 404: unknown
+}
+
+export type GetLegacyHistoryByIdData = {
+ body?: never
+ path: {
+ prompt_id: string
+ }
+ query?: never
+ url: '/api/history/{prompt_id}'
+}
+
+export type GetLegacyHistoryByIdErrors = {
+ /**
+ * Not Found — use /api/jobs/{prompt_id} instead
+ */
+ 404: unknown
+}
+
+export type GetLegacyJobByIdData = {
+ body?: never
+ path: {
+ job_id: string
+ }
+ query?: never
+ url: '/api/job/{job_id}'
+}
+
+export type GetLegacyJobByIdErrors = {
+ /**
+ * Not Found — use /api/jobs/{job_id} instead
+ */
+ 404: unknown
+}
+
+export type GetLegacyJobOutputsData = {
+ body?: never
+ path: {
+ job_id: string
+ }
+ query?: never
+ url: '/api/job/{job_id}/outputs'
+}
+
+export type GetLegacyJobOutputsErrors = {
+ /**
+ * Not Found — use /api/jobs/{job_id} instead
+ */
+ 404: unknown
+}
+
+export type GetLegacyModelsData = {
+ body?: never
+ path?: never
+ query?: never
+ url: '/api/models'
+}
+
+export type GetLegacyModelsErrors = {
+ /**
+ * Not Found — use /api/experiment/models instead
+ */
+ 404: unknown
+}
+
+export type GetLegacyModelsByFolderData = {
+ body?: never
+ path: {
+ folder: string
+ }
+ query?: never
+ url: '/api/models/{folder}'
+}
+
+export type GetLegacyModelsByFolderErrors = {
+ /**
+ * Not Found — use /api/experiment/models/{folder} instead
+ */
+ 404: unknown
+}
+
+export type GetLegacyObjectInfoByNodeClassData = {
+ body?: never
+ path: {
+ node_class: string
+ }
+ query?: never
+ url: '/api/object_info/{node_class}'
+}
+
+export type GetLegacyObjectInfoByNodeClassErrors = {
+ /**
+ * Not Found — use /api/object_info instead
+ */
+ 404: unknown
+}
+
+export type GetLegacyUserdataV2Data = {
+ body?: never
+ path?: never
+ query?: never
+ url: '/api/v2/userdata'
+}
+
+export type GetLegacyUserdataV2Errors = {
+ /**
+ * Not Found — use /api/userdata instead
+ */
+ 404: unknown
+}
+
+export type GetLegacyAssetContentData = {
+ body?: never
+ path: {
+ id: string
+ }
+ query?: never
+ url: '/api/assets/{id}/content'
+}
+
+export type GetLegacyAssetContentErrors = {
+ /**
+ * Not Found — use /api/assets/download instead
+ */
+ 404: unknown
+}
+
+export type GetLegacyViewMetadataData = {
+ body?: never
+ path: {
+ folder_name: string
+ }
+ query?: never
+ url: '/api/view_metadata/{folder_name}'
+}
+
+export type GetLegacyViewMetadataErrors = {
+ /**
+ * Not Found — use /api/experiment/models instead
+ */
+ 404: unknown
+}
diff --git a/packages/ingest-types/src/zod.gen.ts b/packages/ingest-types/src/zod.gen.ts
index 1ad5981dd8..117a53448d 100644
--- a/packages/ingest-types/src/zod.gen.ts
+++ b/packages/ingest-types/src/zod.gen.ts
@@ -2,6 +2,9 @@
import { z } from 'zod'
+/**
+ * Response indicating whether a Hub username is available.
+ */
export const zHubUsernameCheckResponse = z.object({
username: z.string(),
available: z.boolean(),
@@ -9,12 +12,18 @@ export const zHubUsernameCheckResponse = z.object({
validation_error: z.string().optional()
})
+/**
+ * Response containing a signed upload URL and the target asset path.
+ */
export const zHubAssetUploadUrlResponse = z.object({
upload_url: z.string(),
public_url: z.string(),
token: z.string()
})
+/**
+ * Request body for requesting a signed upload URL for a Hub asset.
+ */
export const zHubAssetUploadUrlRequest = z.object({
filename: z.string(),
content_type: z.string()
@@ -46,6 +55,9 @@ export const zUpdateHubWorkflowRequest = z.object({
metadata: z.record(z.unknown()).nullish()
})
+/**
+ * Request body for publishing or updating a workflow on the Hub.
+ */
export const zPublishHubWorkflowRequest = z.object({
username: z.string(),
name: z.string(),
@@ -63,6 +75,9 @@ export const zPublishHubWorkflowRequest = z.object({
sample_image_tokens_or_urls: z.array(z.string()).optional()
})
+/**
+ * Lightweight asset reference used in workflow publishing payloads.
+ */
export const zAssetInfo = z.object({
id: z.string(),
name: z.string(),
@@ -73,12 +88,18 @@ export const zAssetInfo = z.object({
in_library: z.boolean()
})
+/**
+ * Abbreviated Hub profile used in workflow listings.
+ */
export const zHubProfileSummary = z.object({
username: z.string(),
display_name: z.string().optional(),
avatar_url: z.string().optional()
})
+/**
+ * Reference to a Hub label by ID.
+ */
export const zLabelRef = z.object({
name: z.string(),
display_name: z.string()
@@ -94,6 +115,9 @@ export const zHubWorkflowStatus = z.enum([
'deprecated'
])
+/**
+ * Full Hub workflow detail including versions, assets, and statistics.
+ */
export const zHubWorkflowDetail = z.object({
share_id: z.string(),
workflow_id: z.string(),
@@ -115,6 +139,9 @@ export const zHubWorkflowDetail = z.object({
profile: zHubProfileSummary
})
+/**
+ * Abbreviated Hub workflow metadata used in search and listing results.
+ */
export const zHubWorkflowSummary = z.object({
share_id: z.string(),
name: z.string(),
@@ -133,11 +160,17 @@ export const zHubWorkflowSummary = z.object({
sample_image_urls: z.array(z.string()).optional()
})
+/**
+ * Paginated list of Hub workflows matching search criteria.
+ */
export const zHubWorkflowListResponse = z.object({
workflows: z.array(z.union([zHubWorkflowSummary, zHubWorkflowDetail])),
next_cursor: z.string().optional()
})
+/**
+ * Metadata for a single Hub label.
+ */
export const zHubLabelInfo = z.object({
name: z.string(),
display_name: z.string(),
@@ -145,10 +178,16 @@ export const zHubLabelInfo = z.object({
type: z.enum(['tag', 'model', 'custom_node'])
})
+/**
+ * List of available Hub labels for categorizing workflows.
+ */
export const zHubLabelListResponse = z.object({
labels: z.array(zHubLabelInfo)
})
+/**
+ * Entry in the curated workflow template gallery shown on the home page.
+ */
export const zHubWorkflowTemplateEntry = z.object({
name: z.string(),
title: z.string(),
@@ -227,6 +266,9 @@ export const zHubWorkflowTemplateEntry = z.object({
contentTemplate: z.string().optional()
})
+/**
+ * Request body for updating an existing Hub profile.
+ */
export const zUpdateHubProfileRequest = z.object({
display_name: z.string().optional(),
description: z.string().optional(),
@@ -234,6 +276,9 @@ export const zUpdateHubProfileRequest = z.object({
website_urls: z.array(z.string()).optional()
})
+/**
+ * Request body for creating a new Hub profile.
+ */
export const zCreateHubProfileRequest = z.object({
workspace_id: z.string(),
username: z.string(),
@@ -243,6 +288,9 @@ export const zCreateHubProfileRequest = z.object({
website_urls: z.array(z.string()).optional()
})
+/**
+ * Full public profile for a Hub creator.
+ */
export const zHubProfile = z.object({
username: z.string(),
display_name: z.string().optional(),
@@ -251,14 +299,24 @@ export const zHubProfile = z.object({
website_urls: z.array(z.string()).optional()
})
+/**
+ * Response after importing published workflow assets.
+ */
export const zImportPublishedAssetsResponse = z.object({
assets: z.array(zAssetInfo)
})
+/**
+ * Request body for importing assets from a published workflow.
+ */
export const zImportPublishedAssetsRequest = z.object({
- published_asset_ids: z.array(z.string())
+ published_asset_ids: z.array(z.string()),
+ share_id: z.string().nullish()
})
+/**
+ * Full detail of a publicly published workflow on the Hub.
+ */
export const zPublishedWorkflowDetail = z.object({
share_id: z.string(),
workflow_id: z.string(),
@@ -269,18 +327,30 @@ export const zPublishedWorkflowDetail = z.object({
assets: z.array(zAssetInfo)
})
+/**
+ * Response containing assets associated with a workflow.
+ */
export const zWorkflowApiAssetsResponse = z.object({
assets: z.array(zAssetInfo)
})
+/**
+ * Request body for querying assets associated with a workflow.
+ */
export const zWorkflowApiAssetsRequest = z.object({
workflow_api_json: z.record(z.unknown())
})
+/**
+ * Request body for publishing workflow assets to the Hub.
+ */
export const zPublishWorkflowAssetsRequest = z.object({
asset_ids: z.array(z.string())
})
+/**
+ * Publishing metadata for a workflow shared to the Hub.
+ */
export const zWorkflowPublishInfo = z.object({
workflow_id: z.string(),
share_id: z.string(),
@@ -289,11 +359,17 @@ export const zWorkflowPublishInfo = z.object({
assets: z.array(zAssetInfo)
})
+/**
+ * Request body for forking an existing workflow into the user's account.
+ */
export const zForkWorkflowRequest = z.object({
source_version: z.number().int(),
name: z.string().optional()
})
+/**
+ * Full workflow version including the serialized workflow JSON.
+ */
export const zWorkflowVersionContentResponse = z.object({
id: z.string(),
version: z.number().int(),
@@ -303,6 +379,9 @@ export const zWorkflowVersionContentResponse = z.object({
dependency_asset_ids: z.array(z.string()).optional()
})
+/**
+ * Metadata for a single workflow version.
+ */
export const zWorkflowVersionResponse = z.object({
id: z.string(),
version: z.number().int(),
@@ -311,11 +390,17 @@ export const zWorkflowVersionResponse = z.object({
created_at: z.string().datetime()
})
+/**
+ * Request body for creating a new version of a saved workflow.
+ */
export const zCreateWorkflowVersionRequest = z.object({
base_version: z.number().int(),
workflow_json: z.record(z.unknown())
})
+/**
+ * Offset/limit-based pagination metadata included in list responses.
+ */
export const zPaginationInfo = z.object({
offset: z.number().int().gte(0),
limit: z.number().int().gte(1),
@@ -323,11 +408,17 @@ export const zPaginationInfo = z.object({
has_more: z.boolean()
})
+/**
+ * Reference to the parent workflow from which this workflow was forked.
+ */
export const zWorkflowForkedFrom = z.object({
workflow_id: z.string().optional(),
workflow_version_id: z.string().optional()
})
+/**
+ * Full workflow entity including metadata and version history.
+ */
export const zWorkflowResponse = z.object({
id: z.string(),
name: z.string().optional(),
@@ -340,17 +431,26 @@ export const zWorkflowResponse = z.object({
updated_at: z.string().datetime()
})
+/**
+ * Paginated list of saved workflows.
+ */
export const zWorkflowListResponse = z.object({
data: z.array(zWorkflowResponse),
pagination: zPaginationInfo
})
+/**
+ * Request body for updating an existing saved workflow.
+ */
export const zUpdateWorkflowRequest = z.object({
name: z.string().optional(),
description: z.string().optional(),
default_view: z.enum(['workflow', 'app']).optional()
})
+/**
+ * Request body for creating a new saved workflow.
+ */
export const zCreateWorkflowRequest = z.object({
name: z.string().optional(),
description: z.string().optional(),
@@ -360,10 +460,16 @@ export const zCreateWorkflowRequest = z.object({
forked_from_workflow_version_id: z.string().optional()
})
+/**
+ * Response after recording partner usage data.
+ */
export const zPartnerUsageResponse = z.object({
status: z.string()
})
+/**
+ * Request body for reporting partner resource usage (admin endpoint).
+ */
export const zPartnerUsageRequest = z.object({
workspace_id: z.string().min(1),
user_id: z.string().optional(),
@@ -373,6 +479,9 @@ export const zPartnerUsageRequest = z.object({
properties: z.record(z.unknown()).optional()
})
+/**
+ * Status of an asynchronous billing operation.
+ */
export const zBillingOpStatusResponse = z.object({
id: z.string(),
status: z.enum(['pending', 'succeeded', 'failed']),
@@ -381,6 +490,9 @@ export const zBillingOpStatusResponse = z.object({
completed_at: z.string().datetime().optional()
})
+/**
+ * Response after successfully purchasing a credit top-up.
+ */
export const zCreateTopupResponse = z.object({
billing_op_id: z.string(),
topup_id: z.string(),
@@ -395,6 +507,9 @@ export const zCreateTopupResponse = z.object({
})
})
+/**
+ * Request body for purchasing a one-time credit top-up.
+ */
export const zCreateTopupRequest = z.object({
amount_cents: z.coerce
.bigint()
@@ -405,33 +520,54 @@ export const zCreateTopupRequest = z.object({
idempotency_key: z.string().optional()
})
+/**
+ * Response containing a redirect URL to the payment portal.
+ */
export const zPaymentPortalResponse = z.object({
url: z.string()
})
+/**
+ * Request body for generating a payment portal session URL.
+ */
export const zPaymentPortalRequest = z.object({
return_url: z.string().optional()
})
+/**
+ * Response after successfully resubscribing to a billing plan.
+ */
export const zResubscribeResponse = z.object({
billing_op_id: z.string(),
status: z.enum(['active']),
message: z.string().optional()
})
+/**
+ * Request body for reactivating a previously cancelled subscription.
+ */
export const zResubscribeRequest = z.object({
idempotency_key: z.string().optional()
})
+/**
+ * Response after successfully cancelling a subscription.
+ */
export const zCancelSubscriptionResponse = z.object({
billing_op_id: z.string(),
cancel_at: z.string().datetime()
})
+/**
+ * Request body for cancelling the current subscription.
+ */
export const zCancelSubscriptionRequest = z.object({
idempotency_key: z.string().optional()
})
+/**
+ * Response after successfully subscribing to a billing plan.
+ */
export const zSubscribeResponse = z.object({
billing_op_id: z.string(),
status: z.enum(['subscribed', 'needs_payment_method', 'pending_payment']),
@@ -439,6 +575,9 @@ export const zSubscribeResponse = z.object({
payment_method_url: z.string().optional()
})
+/**
+ * Request body for subscribing a workspace to a billing plan.
+ */
export const zSubscribeRequest = z.object({
plan_slug: z.string(),
idempotency_key: z.string().optional(),
@@ -513,6 +652,9 @@ export const zPreviewPlanInfo = z.object({
period_end: z.string().datetime().optional()
})
+/**
+ * Itemized cost preview for a pending subscription change.
+ */
export const zPreviewSubscribeResponse = z.object({
allowed: z.boolean(),
reason: z.string().optional(),
@@ -560,6 +702,9 @@ export const zPreviewSubscribeResponse = z.object({
new_plan: zPreviewPlanInfo
})
+/**
+ * Request body for previewing the cost of a plan subscription change.
+ */
export const zPreviewSubscribeRequest = z.object({
plan_slug: z.string()
})
@@ -575,11 +720,17 @@ export const zPlanAvailabilityReason = z.enum([
'exceeds_max_seats'
])
+/**
+ * Availability and eligibility information for a billing plan.
+ */
export const zPlanAvailability = z.object({
available: z.boolean(),
reason: zPlanAvailabilityReason.optional()
})
+/**
+ * Billing plan details including pricing, limits, and features.
+ */
export const zPlan = z.object({
slug: z.string(),
tier: zSubscriptionTier,
@@ -612,11 +763,17 @@ export const zPlan = z.object({
seat_summary: zPlanSeatSummary
})
+/**
+ * List of available billing plans for subscription.
+ */
export const zBillingPlansResponse = z.object({
current_plan_slug: z.string().optional(),
plans: z.array(zPlan)
})
+/**
+ * User secret metadata (the secret value itself is never returned after creation).
+ */
export const zSecretResponse = z.object({
id: z.string().uuid(),
name: z.string(),
@@ -626,21 +783,33 @@ export const zSecretResponse = z.object({
updated_at: z.string().datetime()
})
+/**
+ * List of user secrets with metadata only.
+ */
export const zSecretListResponse = z.object({
data: z.array(zSecretResponse)
})
+/**
+ * Request body for updating an existing user secret.
+ */
export const zUpdateSecretRequest = z.object({
name: z.string().min(1).max(255).optional(),
secret_value: z.string().min(1).optional()
})
+/**
+ * Request body for creating a new user secret.
+ */
export const zCreateSecretRequest = z.object({
name: z.string().min(1).max(255),
provider: z.string().max(64).optional(),
secret_value: z.string().min(1)
})
+/**
+ * A single billing event such as a charge, credit, or adjustment.
+ */
export const zBillingEvent = z.object({
event_type: z.string(),
event_id: z.string(),
@@ -648,6 +817,9 @@ export const zBillingEvent = z.object({
createdAt: z.string().datetime()
})
+/**
+ * Paginated list of billing events for a workspace.
+ */
export const zBillingEventsResponse = z.object({
total: z.number().int(),
events: z.array(zBillingEvent),
@@ -656,6 +828,9 @@ export const zBillingEventsResponse = z.object({
totalPages: z.number().int()
})
+/**
+ * Current credit balance and usage details for a workspace.
+ */
export const zBillingBalanceResponse = z.object({
amount_micros: z.number(),
prepaid_balance_micros: z.number().optional(),
@@ -676,6 +851,9 @@ export const zBillingStatus = z.enum([
'inactive'
])
+/**
+ * Current billing and subscription status for a workspace.
+ */
export const zBillingStatusResponse = z.object({
is_active: z.boolean(),
subscription_status: z.enum(['active', 'ended', 'canceled']).optional(),
@@ -688,6 +866,9 @@ export const zBillingStatusResponse = z.object({
renewal_date: z.string().datetime().optional()
})
+/**
+ * A single JSON Web Key entry within a JWKS response.
+ */
export const zJwkKey = z.object({
kty: z.string(),
crv: z.string(),
@@ -698,10 +879,32 @@ export const zJwkKey = z.object({
y: z.string()
})
+/**
+ * JSON Web Key Set containing the public keys used to verify Cloud JWTs.
+ */
export const zJwksResponse = z.object({
keys: z.array(zJwkKey)
})
+/**
+ * Response after synchronizing an API key into the local database.
+ */
+export const zSyncApiKeyResponse = z.object({
+ result: z.enum(['revoked', 'already_revoked', 'no_op'])
+})
+
+/**
+ * Request body for synchronizing an API key from the external registry.
+ */
+export const zSyncApiKeyRequest = z.object({
+ event: z.enum(['delete']),
+ key_hash: z.string().regex(/^[0-9a-fA-F]{64}$/),
+ customer_id: z.string().min(1)
+})
+
+/**
+ * Response confirming the validity and scope of a workspace API key.
+ */
export const zVerifyApiKeyResponse = z.object({
user_id: z.string(),
email: z.string(),
@@ -715,10 +918,23 @@ export const zVerifyApiKeyResponse = z.object({
permissions: z.array(z.string())
})
+/**
+ * Request body for verifying a workspace API key (admin endpoint).
+ */
export const zVerifyApiKeyRequest = z.object({
api_key: z.string()
})
+/**
+ * Response after bulk-revoking API keys for a workspace member.
+ */
+export const zBulkRevokeApiKeysResponse = z.object({
+ revoked_count: z.number().int().gte(0)
+})
+
+/**
+ * Metadata for a workspace-scoped API key (secret is never returned).
+ */
export const zWorkspaceApiKeyInfo = z.object({
id: z.string().uuid(),
workspace_id: z.string(),
@@ -731,10 +947,16 @@ export const zWorkspaceApiKeyInfo = z.object({
created_at: z.string().datetime()
})
+/**
+ * List of API keys associated with the current workspace.
+ */
export const zListWorkspaceApiKeysResponse = z.object({
api_keys: z.array(zWorkspaceApiKeyInfo)
})
+/**
+ * Response containing the newly created workspace API key.
+ */
export const zCreateWorkspaceApiKeyResponse = z.object({
id: z.string().uuid(),
name: z.string(),
@@ -744,20 +966,32 @@ export const zCreateWorkspaceApiKeyResponse = z.object({
created_at: z.string().datetime()
})
+/**
+ * Request body for creating a new workspace-scoped API key.
+ */
export const zCreateWorkspaceApiKeyRequest = z.object({
name: z.string(),
expires_at: z.string().datetime().optional()
})
+/**
+ * Response returned after successfully accepting a workspace invitation.
+ */
export const zAcceptInviteResponse = z.object({
workspace_id: z.string(),
workspace_name: z.string()
})
+/**
+ * Request body for inviting a user to a workspace.
+ */
export const zCreateInviteRequest = z.object({
email: z.string().email()
})
+/**
+ * An outstanding workspace invitation that has not yet been accepted.
+ */
export const zPendingInvite = z.object({
id: z.string(),
email: z.string().email(),
@@ -766,10 +1000,16 @@ export const zPendingInvite = z.object({
expires_at: z.string().datetime()
})
+/**
+ * List of pending invitations for the current workspace.
+ */
export const zListInvitesResponse = z.object({
invites: z.array(zPendingInvite)
})
+/**
+ * Workspace member with profile and role information.
+ */
export const zMember = z.object({
id: z.string(),
name: z.string(),
@@ -778,19 +1018,31 @@ export const zMember = z.object({
joined_at: z.string().datetime()
})
+/**
+ * List of members in the current workspace.
+ */
export const zListMembersResponse = z.object({
members: z.array(zMember),
pagination: zPaginationInfo
})
+/**
+ * Request body for updating an existing workspace's settings.
+ */
export const zUpdateWorkspaceRequest = z.object({
name: z.string().min(1).max(100).optional()
})
+/**
+ * Request body for creating a new workspace.
+ */
export const zCreateWorkspaceRequest = z.object({
name: z.string().min(1).max(100)
})
+/**
+ * Workspace entity annotated with the requesting user's role.
+ */
export const zWorkspaceWithRole = z.object({
id: z.string(),
name: z.string(),
@@ -801,10 +1053,16 @@ export const zWorkspaceWithRole = z.object({
subscription_tier: zSubscriptionTier.optional()
})
+/**
+ * Paginated list of workspaces the authenticated user belongs to.
+ */
export const zListWorkspacesResponse = z.object({
workspaces: z.array(zWorkspaceWithRole)
})
+/**
+ * Full workspace entity with configuration and ownership details.
+ */
export const zWorkspace = z.object({
id: z.string(),
name: z.string(),
@@ -812,12 +1070,18 @@ export const zWorkspace = z.object({
created_at: z.string().datetime()
})
+/**
+ * Abbreviated workspace metadata used in list responses.
+ */
export const zWorkspaceSummary = z.object({
id: z.string(),
name: z.string(),
type: z.enum(['personal', 'team'])
})
+/**
+ * Response containing the issued Cloud JWT and its expiry.
+ */
export const zExchangeTokenResponse = z.object({
token: z.string(),
expires_at: z.string().datetime(),
@@ -826,6 +1090,12 @@ export const zExchangeTokenResponse = z.object({
permissions: z.array(z.string())
})
+/**
+ * Optional request body for the token exchange endpoint. The Firebase JWT
+ * being exchanged is supplied via the `Authorization: Bearer` header; this
+ * body only carries workspace-selection input.
+ *
+ */
export const zExchangeTokenRequest = z.object({
workspace_id: z.string().optional()
})
@@ -859,16 +1129,25 @@ export const zTaskEntry = z.object({
completed_at: z.string().datetime().optional()
})
+/**
+ * Paginated list of background tasks for the authenticated user.
+ */
export const zTasksListResponse = z.object({
tasks: z.array(zTaskEntry),
pagination: zPaginationInfo
})
+/**
+ * Current status of a user data deletion request.
+ */
export const zDeletionStatus = z.object({
status_name: z.string(),
status_details: z.string()
})
+/**
+ * Details of a pending or completed user data deletion request.
+ */
export const zDeletionRequest = z.object({
id: z.string(),
firebase_id: z.string(),
@@ -927,6 +1206,13 @@ export const zJobDetailResponse = z.object({
execution_meta: z.record(z.unknown()).optional()
})
+/**
+ * Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.
+ */
+export const zJobCancelResponse = z.object({
+ cancelled: z.boolean()
+})
+
/**
* Lightweight job data for list views (workflow and full outputs excluded)
*/
@@ -971,11 +1257,17 @@ export const zJobEntry = z.object({
.optional()
})
+/**
+ * Paginated list of jobs for the authenticated user.
+ */
export const zJobsListResponse = z.object({
jobs: z.array(zJobEntry),
pagination: zPaginationInfo
})
+/**
+ * Response after adding, updating, or removing tags on an asset.
+ */
export const zTagsModificationResponse = z.object({
added: z.array(z.string()).optional(),
removed: z.array(z.string()).optional(),
@@ -984,24 +1276,36 @@ export const zTagsModificationResponse = z.object({
total_tags: z.array(z.string())
})
+/**
+ * Details of a single validation error encountered during asset operations.
+ */
export const zValidationError = z.object({
code: z.string(),
message: z.string(),
field: z.string()
})
+/**
+ * Result of validating a set of asset operations.
+ */
export const zValidationResult = z.object({
is_valid: z.boolean(),
errors: z.array(zValidationError).optional(),
warnings: z.array(zValidationError).optional()
})
+/**
+ * Acknowledgement of an async asset download task; clients poll GET /api/tasks/{task_id} for status.
+ */
export const zAssetDownloadResponse = z.object({
task_id: z.string().uuid(),
status: z.enum(['created', 'running', 'completed', 'failed']),
message: z.string().optional()
})
+/**
+ * Metadata for a remotely hosted asset resolved by URL.
+ */
export const zAssetMetadataResponse = z.object({
content_length: z.coerce
.bigint()
@@ -1019,21 +1323,33 @@ export const zAssetMetadataResponse = z.object({
validation: zValidationResult.optional()
})
+/**
+ * Histogram of tag counts used for refining asset search results.
+ */
export const zAssetTagHistogramResponse = z.object({
tag_counts: z.record(z.number().int())
})
+/**
+ * Metadata for a single tag that can be applied to assets.
+ */
export const zTagInfo = z.object({
name: z.string(),
count: z.number().int()
})
+/**
+ * Paginated list of available asset tags.
+ */
export const zListTagsResponse = z.object({
tags: z.array(zTagInfo),
total: z.number().int(),
has_more: z.boolean()
})
+/**
+ * Represents a user-owned asset (image, video, or other generated output).
+ */
export const zAsset = z.object({
id: z.string().uuid(),
name: z.string(),
@@ -1056,18 +1372,25 @@ export const zAsset = z.object({
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
+ job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
last_access_time: z.string().datetime().optional(),
is_immutable: z.boolean().optional()
})
+/**
+ * Paginated list of assets belonging to the authenticated user.
+ */
export const zListAssetsResponse = z.object({
assets: z.array(zAsset),
total: z.number().int(),
has_more: z.boolean()
})
+/**
+ * Response returned when an existing asset is successfully updated.
+ */
export const zAssetUpdated = z.object({
id: z.string().uuid(),
name: z.string().optional(),
@@ -1081,17 +1404,26 @@ export const zAssetUpdated = z.object({
updated_at: z.string().datetime()
})
+/**
+ * Response returned when a new asset is successfully created.
+ */
export const zAssetCreated = zAsset.and(
z.object({
created_new: z.boolean()
})
)
+/**
+ * Response after updating the review status of a Hub workflow.
+ */
export const zSetReviewStatusResponse = z.object({
share_ids: z.array(z.string()),
status: z.enum(['approved', 'rejected'])
})
+/**
+ * Request body for setting the review status of a Hub workflow.
+ */
export const zSetReviewStatusRequest = z.object({
share_ids: z.array(z.string()).min(1),
status: z.enum(['approved', 'rejected'])
@@ -1196,6 +1528,9 @@ export const zModelFolder = z.object({
*/
export const zPromptErrorResponse = z.record(z.unknown())
+/**
+ * Individual file entry within a full user data response.
+ */
export const zGetUserDataResponseFullFile = z.object({
path: z.string().optional(),
size: z.number().int().optional(),
@@ -1210,8 +1545,14 @@ export const zGetUserDataResponseFullFile = z.object({
.optional()
})
+/**
+ * List of user data file entries (each with path, size, and modification time) returned when full_info=true.
+ */
export const zGetUserDataResponseFull = z.array(zGetUserDataResponseFullFile)
+/**
+ * User data listing entry with file metadata (path, size, modification time).
+ */
export const zUserDataResponseFull = z.object({
path: z.string().optional(),
size: z.number().int().optional(),
@@ -1254,6 +1595,9 @@ export const zJobStatusResponse = z.object({
error_message: z.string().nullish()
})
+/**
+ * Response after a queue management action (delete or clear).
+ */
export const zQueueManageResponse = z.object({
deleted: z.array(z.string()).optional(),
cleared: z.boolean().optional()
@@ -1369,6 +1713,9 @@ export const zGlobalSubgraphInfo = z.object({
data: z.string().optional()
})
+/**
+ * Metadata describing a single ComfyUI node type and its inputs/outputs.
+ */
export const zNodeInfo = z.object({
input: z.record(z.unknown()).optional(),
input_order: z.record(z.array(z.string())).optional(),
@@ -1387,6 +1734,9 @@ export const zNodeInfo = z.object({
api_node: z.boolean().optional()
})
+/**
+ * Metadata about the currently running and queued prompts.
+ */
export const zPromptInfo = z.object({
exec_info: z
.object({
@@ -1395,26 +1745,41 @@ export const zPromptInfo = z.object({
.optional()
})
+/**
+ * Response containing a signed download URL for an exported asset archive.
+ */
export const zExportDownloadUrlResponse = z.object({
url: z.string(),
expires_at: z.string().datetime().optional()
})
+/**
+ * Error shape returned when request binding or validation fails before the handler runs.
+ */
export const zBindingErrorResponse = z.object({
message: z.string()
})
+/**
+ * Standard error response with a machine-readable code and human-readable message.
+ */
export const zErrorResponse = z.object({
code: z.string(),
message: z.string()
})
+/**
+ * Response returned after successfully queuing a workflow prompt.
+ */
export const zPromptResponse = z.object({
prompt_id: z.string().uuid().optional(),
number: z.number().optional(),
node_errors: z.record(z.unknown()).optional()
})
+/**
+ * Request body for submitting a ComfyUI workflow prompt for execution.
+ */
export const zPromptRequest = z.object({
prompt: z.record(z.unknown()),
number: z.number().optional(),
@@ -1425,6 +1790,9 @@ export const zPromptRequest = z.object({
workflow_version_id: z.string().optional()
})
+/**
+ * Represents a user-owned asset (image, video, or other generated output).
+ */
export const zAssetWritable = z.object({
id: z.string().uuid(),
name: z.string(),
@@ -1446,18 +1814,25 @@ export const zAssetWritable = z.object({
preview_url: z.string().url().optional(),
preview_id: z.string().uuid().nullish(),
prompt_id: z.string().uuid().nullish(),
+ job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
last_access_time: z.string().datetime().optional(),
is_immutable: z.boolean().optional()
})
+/**
+ * Paginated list of assets belonging to the authenticated user.
+ */
export const zListAssetsResponseWritable = z.object({
assets: z.array(zAssetWritable),
total: z.number().int(),
has_more: z.boolean()
})
+/**
+ * Response returned when a new asset is successfully created.
+ */
export const zAssetCreatedWritable = zAssetWritable.and(
z.object({
created_new: z.boolean()
@@ -1601,6 +1976,12 @@ export const zGetModelPreviewData = z.object({
*/
export const zGetModelPreviewResponse = z.string()
+export const zGetLegacyHistoryData = z.object({
+ body: z.never().optional(),
+ path: z.never().optional(),
+ query: z.never().optional()
+})
+
export const zManageHistoryData = z.object({
body: zHistoryManageRequest,
path: z.never().optional(),
@@ -1670,6 +2051,19 @@ export const zGetJobDetailData = z.object({
*/
export const zGetJobDetailResponse = zJobDetailResponse
+export const zCancelJobData = z.object({
+ body: z.never().optional(),
+ path: z.object({
+ job_id: z.string().uuid()
+ }),
+ query: z.never().optional()
+})
+
+/**
+ * Success - Cancel request accepted (or job was already terminal)
+ */
+export const zCancelJobResponse = zJobCancelResponse
+
export const zViewFileData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -1818,7 +2212,7 @@ export const zCreateAssetExportData = z.object({
job_ids: z.array(z.string()).optional(),
asset_ids: z.array(z.string()).optional(),
naming_strategy: z
- .enum(['group_by_job_id', 'group_by_job_time', 'preserve', 'asset_id'])
+ .enum(['group_by_job_id', 'preserve', 'asset_id', 'group_by_job_time'])
.optional(),
job_asset_name_filters: z.record(z.array(z.string()).min(1)).optional()
}),
@@ -2564,6 +2958,20 @@ export const zRevokeWorkspaceApiKeyData = z.object({
*/
export const zRevokeWorkspaceApiKeyResponse = z.void()
+export const zBulkRevokeWorkspaceMemberApiKeysData = z.object({
+ body: z.never().optional(),
+ path: z.object({
+ user_id: z.string().min(1)
+ }),
+ query: z.never().optional()
+})
+
+/**
+ * Keys revoked successfully
+ */
+export const zBulkRevokeWorkspaceMemberApiKeysResponse =
+ zBulkRevokeApiKeysResponse
+
export const zVerifyWorkspaceApiKeyData = z.object({
body: zVerifyApiKeyRequest,
path: z.never().optional(),
@@ -2670,6 +3078,17 @@ export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
+export const zSyncApiKeyData = z.object({
+ body: zSyncApiKeyRequest,
+ path: z.never().optional(),
+ query: z.never().optional()
+})
+
+/**
+ * Sync processed — see `result` field
+ */
+export const zSyncApiKeyResponse2 = zSyncApiKeyResponse
+
export const zGetJobStatusData = z.object({
body: z.never().optional(),
path: z.object({
@@ -3337,3 +3756,79 @@ export const zPostCustomNodeProxyData = z.object({
}),
query: z.never().optional()
})
+
+export const zGetLegacyPromptByIdData = z.object({
+ body: z.never().optional(),
+ path: z.object({
+ prompt_id: z.string()
+ }),
+ query: z.never().optional()
+})
+
+export const zGetLegacyHistoryByIdData = z.object({
+ body: z.never().optional(),
+ path: z.object({
+ prompt_id: z.string()
+ }),
+ query: z.never().optional()
+})
+
+export const zGetLegacyJobByIdData = z.object({
+ body: z.never().optional(),
+ path: z.object({
+ job_id: z.string()
+ }),
+ query: z.never().optional()
+})
+
+export const zGetLegacyJobOutputsData = z.object({
+ body: z.never().optional(),
+ path: z.object({
+ job_id: z.string()
+ }),
+ query: z.never().optional()
+})
+
+export const zGetLegacyModelsData = z.object({
+ body: z.never().optional(),
+ path: z.never().optional(),
+ query: z.never().optional()
+})
+
+export const zGetLegacyModelsByFolderData = z.object({
+ body: z.never().optional(),
+ path: z.object({
+ folder: z.string()
+ }),
+ query: z.never().optional()
+})
+
+export const zGetLegacyObjectInfoByNodeClassData = z.object({
+ body: z.never().optional(),
+ path: z.object({
+ node_class: z.string()
+ }),
+ query: z.never().optional()
+})
+
+export const zGetLegacyUserdataV2Data = z.object({
+ body: z.never().optional(),
+ path: z.never().optional(),
+ query: z.never().optional()
+})
+
+export const zGetLegacyAssetContentData = z.object({
+ body: z.never().optional(),
+ path: z.object({
+ id: z.string()
+ }),
+ query: z.never().optional()
+})
+
+export const zGetLegacyViewMetadataData = z.object({
+ body: z.never().optional(),
+ path: z.object({
+ folder_name: z.string()
+ }),
+ query: z.never().optional()
+})
diff --git a/packages/object-info-parser/package.json b/packages/object-info-parser/package.json
new file mode 100644
index 0000000000..f6ea11577e
--- /dev/null
+++ b/packages/object-info-parser/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@comfyorg/object-info-parser",
+ "version": "1.0.0",
+ "description": "Shared object_info schemas and helpers",
+ "license": "GPL-3.0-only",
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "test": "vitest run --config ./vitest.config.ts",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "zod": "catalog:",
+ "zod-validation-error": "catalog:"
+ },
+ "devDependencies": {
+ "typescript": "catalog:",
+ "vitest": "catalog:"
+ },
+ "nx": {
+ "tags": [
+ "scope:shared",
+ "type:util"
+ ]
+ }
+}
diff --git a/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts b/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts
new file mode 100644
index 0000000000..6c490b1d1b
--- /dev/null
+++ b/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest'
+
+import { groupNodesByPack } from '../helpers/groupNodesByPack'
+import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
+
+function makeNodeDef(
+ name: string,
+ pythonModule: string,
+ displayName = name
+): ComfyNodeDef {
+ return {
+ name,
+ display_name: displayName,
+ description: '',
+ category: 'test',
+ output_node: false,
+ python_module: pythonModule
+ }
+}
+
+describe('groupNodesByPack', () => {
+ it('excludes core nodes and groups custom nodes by pack id', () => {
+ const grouped = groupNodesByPack({
+ CoreNode: makeNodeDef('CoreNode', 'nodes'),
+ ImpactA: makeNodeDef(
+ 'ImpactA',
+ 'custom_nodes.comfyui-impact-pack.nodes',
+ 'Impact A'
+ ),
+ ImpactB: makeNodeDef(
+ 'ImpactB',
+ 'custom_nodes.comfyui-impact-pack.nodes',
+ 'Impact B'
+ ),
+ AuxNode: makeNodeDef(
+ 'AuxNode',
+ 'custom_nodes.comfyui-controlnet-aux.nodes',
+ 'Aux Node'
+ )
+ })
+
+ expect(grouped).toHaveLength(2)
+ expect(grouped.map((pack) => pack.id)).toEqual([
+ 'comfyui-controlnet-aux',
+ 'comfyui-impact-pack'
+ ])
+ expect(
+ grouped.find((pack) => pack.id === 'comfyui-impact-pack')?.nodes
+ ).toHaveLength(2)
+ expect(
+ grouped.find((pack) => pack.id === 'comfyui-controlnet-aux')?.nodes
+ ).toHaveLength(1)
+ })
+})
diff --git a/src/schemas/nodeDefSchema.validation.test.ts b/packages/object-info-parser/src/__tests__/nodeDefSchema.test.ts
similarity index 51%
rename from src/schemas/nodeDefSchema.validation.test.ts
rename to packages/object-info-parser/src/__tests__/nodeDefSchema.test.ts
index 707eabd3e4..41f02e7a71 100644
--- a/src/schemas/nodeDefSchema.validation.test.ts
+++ b/packages/object-info-parser/src/__tests__/nodeDefSchema.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
-import { validateComfyNodeDef } from '@/schemas/nodeDefSchema'
-import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
+import { validateComfyNodeDef } from '../schemas/nodeDefSchema'
+import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
const EXAMPLE_NODE_DEF: ComfyNodeDef = {
input: {
@@ -23,52 +23,45 @@ const EXAMPLE_NODE_DEF: ComfyNodeDef = {
}
describe('validateNodeDef', () => {
- it('Should accept a valid node definition', async () => {
+ it('accepts a valid node definition', () => {
expect(validateComfyNodeDef(EXAMPLE_NODE_DEF)).not.toBeNull()
})
- describe.each([
+ describe.for([
[{ ckpt_name: ['foo', { default: 1 }] }, ['foo', { default: 1 }]],
- // Extra input spec should be preserved
[{ ckpt_name: ['foo', { bar: 1 }] }, ['foo', { bar: 1 }]],
[{ ckpt_name: ['INT', { bar: 1 }] }, ['INT', { bar: 1 }]],
[{ ckpt_name: [[1, 2, 3], { bar: 1 }] }, [[1, 2, 3], { bar: 1 }]]
])(
'validateComfyNodeDef with various input spec formats',
- (inputSpec, expected) => {
- it(`should accept input spec format: ${JSON.stringify(inputSpec)}`, async () => {
- expect(
- // @ts-expect-error fixme ts strict error
- validateComfyNodeDef({
- ...EXAMPLE_NODE_DEF,
- input: {
- required: inputSpec
- }
- }).input.required.ckpt_name
- ).toEqual(expected)
+ ([inputSpec, expected]) => {
+ it(`accepts input spec format: ${JSON.stringify(inputSpec)}`, () => {
+ const parsed = validateComfyNodeDef({
+ ...EXAMPLE_NODE_DEF,
+ input: {
+ required: inputSpec
+ }
+ })
+ expect(parsed?.input?.required?.ckpt_name).toEqual(expected)
})
}
)
- describe.each([
+ describe.for([
[{ ckpt_name: { 'model1.safetensors': 'foo' } }],
[{ ckpt_name: ['*', ''] }],
[{ ckpt_name: ['foo', { default: 1 }, { default: 2 }] }],
- // Should reject incorrect default value type.
[{ ckpt_name: ['INT', { default: '124' }] }]
- ])(
- 'validateComfyNodeDef rejects with various input spec formats',
- (inputSpec) => {
- it(`should accept input spec format: ${JSON.stringify(inputSpec)}`, async () => {
- expect(
- validateComfyNodeDef({
- ...EXAMPLE_NODE_DEF,
- input: {
- required: inputSpec
- }
- })
- ).toBeNull()
- })
- }
- )
+ ])('validateComfyNodeDef rejects invalid input specs', (inputSpec) => {
+ it(`rejects input spec format: ${JSON.stringify(inputSpec)}`, () => {
+ expect(
+ validateComfyNodeDef({
+ ...EXAMPLE_NODE_DEF,
+ input: {
+ required: inputSpec
+ }
+ })
+ ).toBeNull()
+ })
+ })
})
diff --git a/src/types/nodeSource.test.ts b/packages/object-info-parser/src/__tests__/nodeSource.test.ts
similarity index 97%
rename from src/types/nodeSource.test.ts
rename to packages/object-info-parser/src/__tests__/nodeSource.test.ts
index 215b4106be..5e32966faf 100644
--- a/src/types/nodeSource.test.ts
+++ b/packages/object-info-parser/src/__tests__/nodeSource.test.ts
@@ -5,8 +5,8 @@ import {
getNodeSource,
isCustomNode,
isEssentialNode
-} from '@/types/nodeSource'
-import type { NodeSource } from '@/types/nodeSource'
+} from '../classifiers/nodeSource'
+import type { NodeSource } from '../classifiers/nodeSource'
describe('getNodeSource', () => {
it('should return UNKNOWN_NODE_SOURCE when python_module is undefined', () => {
diff --git a/packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts b/packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts
new file mode 100644
index 0000000000..6d0b91b942
--- /dev/null
+++ b/packages/object-info-parser/src/__tests__/sanitizeUserContent.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest'
+
+import { sanitizeUserContent } from '../helpers/sanitizeUserContent'
+import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
+
+function makeNodeDef(
+ name: string,
+ pythonModule: string,
+ input: ComfyNodeDef['input']
+): ComfyNodeDef {
+ return {
+ name,
+ display_name: name,
+ description: '',
+ category: 'test',
+ output_node: false,
+ python_module: pythonModule,
+ input
+ }
+}
+
+describe('sanitizeUserContent', () => {
+ it('strips known user filenames from combo inputs across all nodes', () => {
+ const defs = {
+ CustomCombo: makeNodeDef('CustomCombo', 'custom_nodes.some-pack', {
+ required: {
+ choice: [['my-secret.png', 'safe-option', 'video.mp4', 42], {}],
+ choiceV2: ['COMBO', { options: ['a.jpg', 'keep-me', 'b'] }]
+ }
+ })
+ }
+
+ const sanitized = sanitizeUserContent(defs)
+ const required = sanitized.CustomCombo.input?.required
+ expect(required?.choice).toEqual([['safe-option', 42], {}])
+ expect(required?.choiceV2).toEqual(['COMBO', { options: ['keep-me', 'b'] }])
+ })
+
+ it('zeros combo lists for known upload nodes in required/optional/hidden sections', () => {
+ const defs = {
+ LoadImage: makeNodeDef('LoadImage', 'nodes', {
+ required: {
+ image: [['personal.png', 'public.png'], { image_upload: true }]
+ },
+ optional: {
+ mask: ['COMBO', { options: ['another.jpg', 'value'] }]
+ },
+ hidden: {
+ cache: [['movie.mov', 'keep'], {}],
+ hiddenV2: ['COMBO', { options: ['private.wav', 'other'] }]
+ }
+ }),
+ LoadVideo: makeNodeDef('LoadVideo', 'nodes', {
+ required: {
+ video: [['clip.mp4', 'clip2.webm'], {}]
+ }
+ }),
+ LoadAudio: makeNodeDef('LoadAudio', 'nodes', {
+ required: {
+ audio: [['song.mp3', 'song.flac'], {}]
+ }
+ })
+ }
+
+ const sanitized = sanitizeUserContent(defs)
+
+ expect(sanitized.LoadImage.input?.required?.image).toEqual([
+ [],
+ { image_upload: true }
+ ])
+ expect(sanitized.LoadImage.input?.optional?.mask).toEqual([
+ 'COMBO',
+ { options: [] }
+ ])
+ expect(sanitized.LoadImage.input?.hidden?.cache).toEqual([[], {}])
+ expect(sanitized.LoadImage.input?.hidden?.hiddenV2).toEqual([
+ 'COMBO',
+ { options: [] }
+ ])
+ expect(sanitized.LoadVideo.input?.required?.video).toEqual([[], {}])
+ expect(sanitized.LoadAudio.input?.required?.audio).toEqual([[], {}])
+ })
+})
diff --git a/packages/object-info-parser/src/classifiers/nodeSource.ts b/packages/object-info-parser/src/classifiers/nodeSource.ts
new file mode 100644
index 0000000000..b2089d7625
--- /dev/null
+++ b/packages/object-info-parser/src/classifiers/nodeSource.ts
@@ -0,0 +1,97 @@
+export const BLUEPRINT_CATEGORY = 'Subgraph Blueprints'
+
+export enum NodeSourceType {
+ Core = 'core',
+ CustomNodes = 'custom_nodes',
+ Blueprint = 'blueprint',
+ Essentials = 'essentials',
+ Unknown = 'unknown'
+}
+export const CORE_NODE_MODULES = ['nodes', 'comfy_extras', 'comfy_api_nodes']
+
+export type NodeSource = {
+ type: NodeSourceType
+ className: string
+ displayText: string
+ badgeText: string
+}
+
+const UNKNOWN_NODE_SOURCE: NodeSource = {
+ type: NodeSourceType.Unknown,
+ className: 'comfy-unknown',
+ displayText: 'Unknown',
+ badgeText: '?'
+}
+
+function shortenNodeName(name: string) {
+ return name
+ .replace(/^(ComfyUI-|ComfyUI_|Comfy-|Comfy_)/, '')
+ .replace(/(-ComfyUI|_ComfyUI|-Comfy|_Comfy)$/, '')
+}
+
+export function getNodeSource(
+ python_module?: string,
+ essentials_category?: string
+): NodeSource {
+ if (!python_module) {
+ return UNKNOWN_NODE_SOURCE
+ }
+ const modules = python_module.split('.')
+ if (essentials_category) {
+ const moduleName = modules[1] ?? modules[0] ?? 'essentials'
+ const displayName = shortenNodeName(moduleName.split('@')[0])
+ return {
+ type: NodeSourceType.Essentials,
+ className: 'comfy-essentials',
+ displayText: displayName,
+ badgeText: displayName
+ }
+ } else if (CORE_NODE_MODULES.includes(modules[0])) {
+ return {
+ type: NodeSourceType.Core,
+ className: 'comfy-core',
+ displayText: 'Comfy Core',
+ badgeText: '🦊'
+ }
+ } else if (modules[0] === 'blueprint') {
+ return {
+ type: NodeSourceType.Blueprint,
+ className: 'blueprint',
+ displayText: 'Blueprint',
+ badgeText: 'bp'
+ }
+ } else if (modules[0] === 'custom_nodes') {
+ const moduleName = modules[1]
+ if (!moduleName) {
+ return UNKNOWN_NODE_SOURCE
+ }
+ const customNodeName = moduleName.split('@')[0]
+ const displayName = shortenNodeName(customNodeName)
+ return {
+ type: NodeSourceType.CustomNodes,
+ className: 'comfy-custom-nodes',
+ displayText: displayName,
+ badgeText: displayName
+ }
+ } else {
+ return UNKNOWN_NODE_SOURCE
+ }
+}
+
+interface NodeDefLike {
+ nodeSource: NodeSource
+}
+
+export function isEssentialNode(node: NodeDefLike): boolean {
+ return node.nodeSource.type === NodeSourceType.Essentials
+}
+
+export function isCustomNode(node: NodeDefLike): boolean {
+ return node.nodeSource.type === NodeSourceType.CustomNodes
+}
+
+export enum NodeBadgeMode {
+ None = 'None',
+ ShowAll = 'Show all',
+ HideBuiltIn = 'Hide built-in'
+}
diff --git a/packages/object-info-parser/src/helpers/groupNodesByPack.ts b/packages/object-info-parser/src/helpers/groupNodesByPack.ts
new file mode 100644
index 0000000000..509205a800
--- /dev/null
+++ b/packages/object-info-parser/src/helpers/groupNodesByPack.ts
@@ -0,0 +1,47 @@
+import { getNodeSource, NodeSourceType } from '../classifiers/nodeSource'
+import type { ComfyNodeDef } from '../schemas/nodeDefSchema'
+
+export interface PackedNode {
+ className: string
+ def: ComfyNodeDef
+}
+
+export interface NodePack {
+ id: string
+ displayName: string
+ nodes: PackedNode[]
+}
+
+export function groupNodesByPack(
+ defs: Record
+): NodePack[] {
+ const byPackId = new Map()
+
+ for (const [className, def] of Object.entries(defs)) {
+ const source = getNodeSource(def.python_module, def.essentials_category)
+ if (source.type !== NodeSourceType.CustomNodes) {
+ continue
+ }
+
+ const packId = def.python_module.split('.')[1]?.split('@')[0]
+ if (!packId) {
+ continue
+ }
+
+ const existing = byPackId.get(packId)
+ const node = { className, def }
+
+ if (existing) {
+ existing.nodes.push(node)
+ continue
+ }
+
+ byPackId.set(packId, {
+ id: packId,
+ displayName: source.displayText,
+ nodes: [node]
+ })
+ }
+
+ return [...byPackId.values()].sort((a, b) => a.id.localeCompare(b.id))
+}
diff --git a/packages/object-info-parser/src/helpers/sanitizeUserContent.ts b/packages/object-info-parser/src/helpers/sanitizeUserContent.ts
new file mode 100644
index 0000000000..c7d18255fd
--- /dev/null
+++ b/packages/object-info-parser/src/helpers/sanitizeUserContent.ts
@@ -0,0 +1,134 @@
+import type {
+ ComfyNodeDef,
+ ComfyInputsSpec,
+ InputSpec,
+ ComboInputSpec,
+ ComboInputSpecV2
+} from '../schemas/nodeDefSchema'
+import {
+ isComboInputSpecV1,
+ isComboInputSpecV2
+} from '../schemas/nodeDefSchema'
+
+const USER_CONTENT_REGEX =
+ /\.(png|jpe?g|webp|gif|mp4|mov|webm|wav|mp3|flac|ogg|safetensors|ckpt|pt)$/i
+
+const KNOWN_USER_UPLOAD_NODES = new Set([
+ 'LoadImage',
+ 'LoadImageMask',
+ 'LoadImageOutput',
+ 'LoadVideo',
+ 'LoadAudio'
+])
+
+export function sanitizeUserContent(
+ defs: Record
+): Record {
+ const nextEntries = Object.entries(defs).map(([className, def]) => [
+ className,
+ sanitizeNode(def)
+ ])
+ return Object.fromEntries(nextEntries) as Record
+}
+
+function sanitizeNode(def: ComfyNodeDef): ComfyNodeDef {
+ if (!def.input) return def
+
+ const shouldClearAllComboOptions =
+ def.python_module === 'nodes' && KNOWN_USER_UPLOAD_NODES.has(def.name)
+
+ return {
+ ...def,
+ input: {
+ ...def.input,
+ required: sanitizeInputSpecSection(
+ def.input.required,
+ shouldClearAllComboOptions
+ ),
+ optional: sanitizeInputSpecSection(
+ def.input.optional,
+ shouldClearAllComboOptions
+ ),
+ hidden: sanitizeHiddenSection(
+ def.input.hidden,
+ shouldClearAllComboOptions
+ )
+ }
+ }
+}
+
+function sanitizeInputSpecSection(
+ section: ComfyInputsSpec['required'] | ComfyInputsSpec['optional'],
+ forceEmpty: boolean
+): ComfyInputsSpec['required'] | ComfyInputsSpec['optional'] {
+ if (!section) return section
+
+ const nextEntries = Object.entries(section).map(([key, value]) => {
+ const sanitized = sanitizeInputSpec(value, forceEmpty) as InputSpec
+ return [key, sanitized] as const
+ })
+
+ return Object.fromEntries(nextEntries)
+}
+
+function sanitizeHiddenSection(
+ section: ComfyInputsSpec['hidden'],
+ forceEmpty: boolean
+): ComfyInputsSpec['hidden'] {
+ if (!section) return section
+
+ const nextEntries = Object.entries(section).map(([key, value]) => [
+ key,
+ sanitizeInputSpec(value, forceEmpty)
+ ])
+
+ return Object.fromEntries(nextEntries)
+}
+
+function sanitizeInputSpec(inputSpec: unknown, forceEmpty: boolean): unknown {
+ if (!Array.isArray(inputSpec)) {
+ return inputSpec
+ }
+
+ if (isComboInputSpecV1(inputSpec as InputSpec)) {
+ return sanitizeComboInputSpecV1(inputSpec as ComboInputSpec, forceEmpty)
+ }
+
+ if (isComboInputSpecV2(inputSpec as InputSpec)) {
+ return sanitizeComboInputSpecV2(inputSpec as ComboInputSpecV2, forceEmpty)
+ }
+
+ return inputSpec
+}
+
+function sanitizeComboInputSpecV1(
+ inputSpec: ComboInputSpec,
+ forceEmpty: boolean
+): ComboInputSpec {
+ const [comboValues, options] = inputSpec
+ const sanitizedValues = forceEmpty ? [] : filterComboValues(comboValues)
+ return [sanitizedValues, options]
+}
+
+function sanitizeComboInputSpecV2(
+ inputSpec: ComboInputSpecV2,
+ forceEmpty: boolean
+): ComboInputSpecV2 {
+ const [comboTag, options] = inputSpec
+ if (!options?.options) {
+ return inputSpec
+ }
+
+ const nextOptions = {
+ ...options,
+ options: forceEmpty ? [] : filterComboValues(options.options)
+ }
+
+ return [comboTag, nextOptions]
+}
+
+function filterComboValues(values: (number | string)[]): (number | string)[] {
+ return values.filter((value) =>
+ typeof value === 'string' ? !USER_CONTENT_REGEX.test(value) : true
+ )
+}
diff --git a/packages/object-info-parser/src/index.ts b/packages/object-info-parser/src/index.ts
new file mode 100644
index 0000000000..f512634cc9
--- /dev/null
+++ b/packages/object-info-parser/src/index.ts
@@ -0,0 +1,4 @@
+export * from './schemas/nodeDefSchema'
+export * from './classifiers/nodeSource'
+export * from './helpers/groupNodesByPack'
+export * from './helpers/sanitizeUserContent'
diff --git a/packages/object-info-parser/src/schemas/nodeDefSchema.ts b/packages/object-info-parser/src/schemas/nodeDefSchema.ts
new file mode 100644
index 0000000000..86963dfee0
--- /dev/null
+++ b/packages/object-info-parser/src/schemas/nodeDefSchema.ts
@@ -0,0 +1,384 @@
+import { z } from 'zod'
+import { fromZodError } from 'zod-validation-error'
+
+const CONTROL_OPTIONS = [
+ 'fixed',
+ 'increment',
+ 'decrement',
+ 'randomize'
+] as const
+const RESULT_ITEM_TYPE = z.enum(['input', 'output', 'temp'])
+
+const zComboOption = z.union([z.string(), z.number()])
+const zRemoteWidgetConfig = z.object({
+ route: z.string().url().or(z.string().startsWith('/')),
+ refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
+ response_key: z.string().optional(),
+ query_params: z.record(z.string(), z.string()).optional(),
+ refresh_button: z.boolean().optional(),
+ control_after_refresh: z.enum(['first', 'last']).optional(),
+ timeout: z.number().gte(0).optional(),
+ max_retries: z.number().gte(0).optional()
+})
+const zMultiSelectOption = z.object({
+ placeholder: z.string().optional(),
+ chip: z.boolean().optional()
+})
+
+export const zBaseInputOptions = z
+ .object({
+ default: z.any().optional(),
+ defaultInput: z.boolean().optional(),
+ display_name: z.string().optional(),
+ forceInput: z.boolean().optional(),
+ tooltip: z.string().optional(),
+ socketless: z.boolean().optional(),
+ hidden: z.boolean().optional(),
+ advanced: z.boolean().optional(),
+ widgetType: z.string().optional(),
+ /** Backend-only properties. */
+ rawLink: z.boolean().optional(),
+ lazy: z.boolean().optional()
+ })
+ .passthrough()
+
+const zNumericInputOptions = zBaseInputOptions.extend({
+ min: z.number().optional(),
+ max: z.number().optional(),
+ step: z.number().optional(),
+ /** Note: Many node authors are using INT/FLOAT to pass list of INT/FLOAT. */
+ default: z.union([z.number(), z.array(z.number())]).optional(),
+ display: z.enum(['slider', 'number', 'knob', 'gradientslider']).optional()
+})
+
+export const zIntInputOptions = zNumericInputOptions.extend({
+ /**
+ * If true, a linked widget will be added to the node to select the mode
+ * of `control_after_generate`.
+ */
+ control_after_generate: z
+ .union([z.boolean(), z.enum(CONTROL_OPTIONS)])
+ .optional()
+})
+
+export const zColorStop = z.object({
+ offset: z.number(),
+ color: z.tuple([z.number(), z.number(), z.number()])
+})
+
+export const zFloatInputOptions = zNumericInputOptions.extend({
+ round: z.union([z.number(), z.literal(false)]).optional(),
+ gradient_stops: z.array(zColorStop).optional()
+})
+
+export const zBooleanInputOptions = zBaseInputOptions.extend({
+ label_on: z.string().optional(),
+ label_off: z.string().optional(),
+ default: z.boolean().optional()
+})
+
+export const zStringInputOptions = zBaseInputOptions.extend({
+ default: z.string().optional(),
+ multiline: z.boolean().optional(),
+ dynamicPrompts: z.boolean().optional(),
+
+ // Multiline-only fields
+ defaultVal: z.string().optional(),
+ placeholder: z.string().optional()
+})
+
+export const zComboInputOptions = zBaseInputOptions.extend({
+ control_after_generate: z
+ .union([z.boolean(), z.enum(CONTROL_OPTIONS)])
+ .optional(),
+ image_upload: z.boolean().optional(),
+ image_folder: RESULT_ITEM_TYPE.optional(),
+ allow_batch: z.boolean().optional(),
+ video_upload: z.boolean().optional(),
+ audio_upload: z.boolean().optional(),
+ mesh_upload: z.boolean().optional(),
+ upload_subfolder: z.string().optional(),
+ animated_image_upload: z.boolean().optional(),
+ options: z.array(zComboOption).optional(),
+ remote: zRemoteWidgetConfig.optional(),
+ /** Whether the widget is a multi-select widget. */
+ multi_select: zMultiSelectOption.optional()
+})
+
+const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])
+const zFloatInputSpec = z.tuple([
+ z.literal('FLOAT'),
+ zFloatInputOptions.optional()
+])
+const zBooleanInputSpec = z.tuple([
+ z.literal('BOOLEAN'),
+ zBooleanInputOptions.optional()
+])
+const zStringInputSpec = z.tuple([
+ z.literal('STRING'),
+ zStringInputOptions.optional()
+])
+/**
+ * Legacy combo syntax.
+ * @deprecated Use `zComboInputSpecV2` instead.
+ */
+const zComboInputSpec = z.tuple([
+ z.array(zComboOption),
+ zComboInputOptions.optional()
+])
+const zComboInputSpecV2 = z.tuple([
+ z.literal('COMBO'),
+ zComboInputOptions.optional()
+])
+
+export function isComboInputSpecV1(
+ inputSpec: InputSpec
+): inputSpec is ComboInputSpec {
+ return Array.isArray(inputSpec[0])
+}
+
+export function isIntInputSpec(
+ inputSpec: InputSpec
+): inputSpec is IntInputSpec {
+ return inputSpec[0] === 'INT'
+}
+
+export function isFloatInputSpec(
+ inputSpec: InputSpec
+): inputSpec is FloatInputSpec {
+ return inputSpec[0] === 'FLOAT'
+}
+
+export function isComboInputSpecV2(
+ inputSpec: InputSpec
+): inputSpec is ComboInputSpecV2 {
+ return inputSpec[0] === 'COMBO'
+}
+
+export function isComboInputSpec(
+ inputSpec: InputSpec
+): inputSpec is ComboInputSpec | ComboInputSpecV2 {
+ return isComboInputSpecV1(inputSpec) || isComboInputSpecV2(inputSpec)
+}
+
+export function isMediaUploadComboInput(inputSpec: InputSpec): boolean {
+ const [inputName, inputOptions] = inputSpec
+ if (!inputOptions) return false
+
+ const isUploadInput =
+ inputOptions['image_upload'] === true ||
+ inputOptions['video_upload'] === true ||
+ inputOptions['animated_image_upload'] === true
+
+ return (
+ isUploadInput && (isComboInputSpecV1(inputSpec) || inputName === 'COMBO')
+ )
+}
+
+/**
+ * Get the type of an input spec.
+ *
+ * @param inputSpec - The input spec to get the type of.
+ * @returns The type of the input spec.
+ */
+export function getInputSpecType(inputSpec: InputSpec): string {
+ return isComboInputSpec(inputSpec) ? 'COMBO' : inputSpec[0]
+}
+
+/**
+ * Get the combo options from a combo input spec.
+ *
+ * @param inputSpec - The input spec to get the combo options from.
+ * @returns The combo options.
+ */
+export function getComboSpecComboOptions(
+ inputSpec: ComboInputSpec | ComboInputSpecV2
+): (number | string)[] {
+ return (
+ (isComboInputSpecV2(inputSpec) ? inputSpec[1]?.options : inputSpec[0]) ?? []
+ )
+}
+
+const excludedLiterals = new Set(['INT', 'FLOAT', 'BOOLEAN', 'STRING', 'COMBO'])
+const zCustomInputSpec = z.tuple([
+ z.string().refine((value) => !excludedLiterals.has(value)),
+ zBaseInputOptions.optional()
+])
+
+const zInputSpec = z.union([
+ zIntInputSpec,
+ zFloatInputSpec,
+ zBooleanInputSpec,
+ zStringInputSpec,
+ zComboInputSpec,
+ zComboInputSpecV2,
+ zCustomInputSpec
+])
+
+export const zComfyInputsSpec = z.object({
+ required: z.record(zInputSpec).optional(),
+ optional: z.record(zInputSpec).optional(),
+ // Frontend repo is not using it, but some custom nodes are using the
+ // hidden field to pass various values.
+ hidden: z.record(z.any()).optional()
+})
+
+const zComfyNodeDataType = z.string()
+const zComfyComboOutput = z.array(zComboOption)
+export const zComfyOutputTypesSpec = z.array(
+ z.union([zComfyNodeDataType, zComfyComboOutput])
+)
+
+/**
+ * Widget dependency with type information.
+ * Provides strong type enforcement for JSONata evaluation context.
+ */
+const zWidgetDependency = z.object({
+ name: z.string(),
+ type: z.string()
+})
+
+export type WidgetDependency = z.infer
+
+/**
+ * Schema for price badge depends_on field.
+ * Specifies which widgets and inputs the pricing expression depends on.
+ * Widgets must be specified as objects with name and type.
+ */
+const zPriceBadgeDepends = z.object({
+ widgets: z.array(zWidgetDependency).optional().default([]),
+ inputs: z.array(z.string()).optional().default([]),
+ /**
+ * Autogrow input group names to track.
+ * For each group, the count of connected inputs will be available in the
+ * JSONata context as `g.`.
+ * Example: `input_groups: ["reference_videos"]` makes `g.reference_videos`
+ * available with the count of connected inputs like `reference_videos.character1`, etc.
+ */
+ input_groups: z.array(z.string()).optional().default([])
+})
+
+/**
+ * Schema for price badge definition.
+ * Used to calculate and display pricing information for API nodes.
+ * The `expr` field contains a JSONata expression that returns a PricingResult.
+ */
+const zPriceBadge = z.object({
+ engine: z.literal('jsonata').optional().default('jsonata'),
+ depends_on: zPriceBadgeDepends
+ .optional()
+ .default({ widgets: [], inputs: [], input_groups: [] }),
+ expr: z.string()
+})
+
+export type PriceBadge = z.infer
+
+export const zComfyNodeDef = z.object({
+ input: zComfyInputsSpec.optional(),
+ output: zComfyOutputTypesSpec.optional(),
+ output_is_list: z.array(z.boolean()).optional(),
+ output_name: z.array(z.string()).optional(),
+ output_tooltips: z.array(z.string()).optional(),
+ output_matchtypes: z.array(z.string().optional()).optional(),
+ name: z.string(),
+ display_name: z.string(),
+ description: z.string(),
+ help: z.string().optional(),
+ category: z.string(),
+ main_category: z.string().optional(),
+ output_node: z.boolean(),
+ python_module: z.string(),
+ deprecated: z.boolean().optional(),
+ experimental: z.boolean().optional(),
+ dev_only: z.boolean().optional(),
+ /**
+ * Whether the node is an API node. Running API nodes requires login to
+ * Comfy Org account.
+ * https://docs.comfy.org/tutorials/api-nodes/overview
+ */
+ api_node: z.boolean().optional(),
+ /**
+ * Specifies the order of inputs for each input category.
+ * Used to ensure consistent widget ordering regardless of JSON serialization.
+ * Keys are 'required', 'optional', etc., values are arrays of input names.
+ */
+ input_order: z.record(z.array(z.string())).optional(),
+ /**
+ * Alternative names for search. Useful for synonyms, abbreviations,
+ * or old names after renaming a node.
+ */
+ search_aliases: z.array(z.string()).optional(),
+ /**
+ * Price badge definition for API nodes.
+ * Contains a JSONata expression to calculate pricing based on widget values
+ * and input connectivity.
+ */
+ price_badge: zPriceBadge.optional(),
+ /** Category for the Essentials tab. If set, the node appears in Essentials. */
+ essentials_category: z.string().optional(),
+ /** Whether the blueprint is a global/installed blueprint (not user-created). */
+ isGlobal: z.boolean().optional()
+})
+
+export const zAutogrowOptions = z.object({
+ ...zBaseInputOptions.shape,
+ template: z.object({
+ input: zComfyInputsSpec,
+ names: z.array(z.string()).optional(),
+ max: z.number().optional(),
+ //Backend defines as mandatory with min 1, Frontend is more forgiving
+ min: z.number().optional(),
+ prefix: z.string().optional()
+ })
+})
+
+export const zDynamicComboInputSpec = z.tuple([
+ z.literal('COMFY_DYNAMICCOMBO_V3'),
+ zBaseInputOptions.extend({
+ options: z.array(
+ z.object({
+ inputs: zComfyInputsSpec,
+ key: z.string()
+ })
+ )
+ })
+])
+
+export const zMatchTypeOptions = z.object({
+ ...zBaseInputOptions.shape,
+ type: z.literal('COMFY_MATCHTYPE_V3'),
+ template: z.object({
+ allowed_types: z.string(),
+ template_id: z.string()
+ })
+})
+
+// `/object_info`
+export type ComfyInputsSpec = z.infer
+export type ComfyOutputTypesSpec = z.infer
+export type ComfyNodeDef = z.infer
+export type RemoteWidgetConfig = z.infer
+
+export type ComboInputOptions = z.infer
+export type NumericInputOptions = z.infer
+
+export type IntInputSpec = z.infer
+export type FloatInputSpec = z.infer
+export type ComboInputSpec = z.infer
+export type ComboInputSpecV2 = z.infer
+export type InputSpec = z.infer
+
+export function validateComfyNodeDef(
+ data: unknown,
+ onError: (error: string) => void = console.warn
+): ComfyNodeDef | null {
+ const result = zComfyNodeDef.safeParse(data)
+ if (!result.success) {
+ const zodError = fromZodError(result.error)
+ onError(
+ `Invalid ComfyNodeDef: ${JSON.stringify(data)}\n${zodError.message}`
+ )
+ return null
+ }
+ return result.data
+}
diff --git a/packages/object-info-parser/tsconfig.json b/packages/object-info-parser/tsconfig.json
new file mode 100644
index 0000000000..ea72a25788
--- /dev/null
+++ b/packages/object-info-parser/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*", "vitest.config.ts"]
+}
diff --git a/packages/object-info-parser/vitest.config.ts b/packages/object-info-parser/vitest.config.ts
new file mode 100644
index 0000000000..911cf80d50
--- /dev/null
+++ b/packages/object-info-parser/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ environment: 'node',
+ include: ['src/__tests__/**/*.test.ts'],
+ globals: false
+ }
+})
diff --git a/packages/registry-types/src/comfyRegistryTypes.ts b/packages/registry-types/src/comfyRegistryTypes.ts
index b13ba80dfc..182adf6eea 100644
--- a/packages/registry-types/src/comfyRegistryTypes.ts
+++ b/packages/registry-types/src/comfyRegistryTypes.ts
@@ -2524,6 +2524,46 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/proxy/luma_2/generations": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Create a Luma Agents generation
+ * @description Submit an image generation or edit job. Returns immediately with an opaque job ID to poll via GET /proxy/luma_2/generations/{id}.
+ */
+ post: operations["lumaAgentsCreateGeneration"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/proxy/luma_2/generations/{generation_id}": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get a Luma Agents generation
+ * @description Poll for generation status and output. On completion, the response includes presigned URLs to download the generated images.
+ */
+ get: operations["lumaAgentsGetGeneration"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/proxy/pixverse/video/text/generate": {
parameters: {
query?: never;
@@ -10017,6 +10057,8 @@ export interface components {
};
progress: number;
create_time: number;
+ /** @description Actual credits consumed by the task. Present once status is finalized; 0 for failed tasks. */
+ consumed_credit?: number;
};
TripoSuccessTask: {
/** @enum {integer} */
@@ -10381,6 +10423,88 @@ export interface components {
/** @description The request of the generation */
request?: components["schemas"]["LumaGenerationRequest"] | components["schemas"]["LumaImageGenerationRequest"] | components["schemas"]["LumaUpscaleVideoGenerationRequest"] | components["schemas"]["LumaAudioGenerationRequest"];
};
+ /**
+ * @description Output aspect ratio
+ * @enum {string}
+ */
+ LumaAgentsAspectRatio: "3:1" | "2:1" | "16:9" | "3:2" | "1:1" | "2:3" | "9:16" | "1:2" | "1:3";
+ /**
+ * @description Style preset
+ * @enum {string}
+ */
+ LumaAgentsStyle: "auto" | "manga";
+ /**
+ * @description Output image format
+ * @enum {string}
+ */
+ LumaAgentsOutputFormat: "png" | "jpeg";
+ /**
+ * @description The kind of generation to perform
+ * @enum {string}
+ */
+ LumaAgentsGenerationType: "image" | "image_edit";
+ /**
+ * @description Current state of the generation
+ * @enum {string}
+ */
+ LumaAgentsState: "queued" | "processing" | "completed" | "failed";
+ /**
+ * @description Machine-readable failure code for programmatic handling
+ * @enum {string}
+ */
+ LumaAgentsFailureCode: "content_moderated" | "generation_failed" | "budget_exhausted" | "output_not_found";
+ /** @description Reference image for style/content guidance or guided generation */
+ LumaAgentsImageRef: {
+ /** @description Base64-encoded image data */
+ data?: string;
+ /** @description MIME type (e.g. image/jpeg). Required with data. */
+ media_type?: string;
+ /** @description Publicly accessible image URL */
+ url?: string;
+ };
+ /** @description The Luma Agents generation request object */
+ LumaAgentsGenerationRequest: {
+ /** @description Text prompt */
+ prompt: string;
+ aspect_ratio?: components["schemas"]["LumaAgentsAspectRatio"];
+ /** @description Reference images for style/content guidance. Up to 9 for type 'image', up to 8 for type 'image_edit'. */
+ image_ref?: components["schemas"]["LumaAgentsImageRef"][];
+ /** @description Model to use */
+ model?: string;
+ output_format?: components["schemas"]["LumaAgentsOutputFormat"];
+ source?: components["schemas"]["LumaAgentsImageRef"];
+ style?: components["schemas"]["LumaAgentsStyle"];
+ type?: components["schemas"]["LumaAgentsGenerationType"];
+ /** @description Enable web search grounding */
+ web_search?: boolean;
+ };
+ /** @description A generated output entry */
+ LumaAgentsGenerationOutput: {
+ /** @description Media type (e.g. image) */
+ type?: string;
+ /** @description Presigned URL (1hr expiry) */
+ url?: string;
+ };
+ /** @description Generation status and output */
+ LumaAgentsGeneration: {
+ /** @description Generation identifier */
+ id?: string;
+ /** @description Creation timestamp */
+ created_at?: string;
+ /** @description Model used */
+ model?: string;
+ state?: components["schemas"]["LumaAgentsState"];
+ type?: components["schemas"]["LumaAgentsGenerationType"];
+ failure_code?: components["schemas"]["LumaAgentsFailureCode"];
+ /** @description Human-readable failure description */
+ failure_reason?: string;
+ output?: components["schemas"]["LumaAgentsGenerationOutput"][];
+ };
+ /** @description The error object */
+ LumaAgentsError: {
+ /** @description The error message */
+ detail?: string;
+ };
PixverseTextVideoRequest: {
/** @enum {string} */
aspect_ratio: "16:9" | "4:3" | "1:1" | "3:4" | "9:16";
@@ -12453,12 +12577,16 @@ export interface components {
Rodin3DGenerateRequest: {
/** @description The reference images to generate 3D Assets. */
images: string;
+ /** @description Text prompt used by the upstream Rodin API. Required by upstream for text-to-3D requests (no images uploaded); optional for image-to-3D requests where it acts as additional guidance. */
+ prompt?: string;
/** @description Seed. */
seed?: number;
tier?: components["schemas"]["RodinTierType"];
material?: components["schemas"]["RodinMaterialType"];
quality?: components["schemas"]["RodinQualityType"];
mesh_mode?: components["schemas"]["RodinMeshModeType"];
+ /** @description Optional list of upstream addon flags (e.g. "HighPack"). */
+ addons?: string[];
};
/**
* @description Rodin Tier para options
@@ -26183,6 +26311,72 @@ export interface operations {
};
};
};
+ lumaAgentsCreateGeneration: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** @description The generation request object */
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["LumaAgentsGenerationRequest"];
+ };
+ };
+ responses: {
+ /** @description Generation accepted */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["LumaAgentsGeneration"];
+ };
+ };
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["LumaAgentsError"];
+ };
+ };
+ };
+ };
+ lumaAgentsGetGeneration: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path: {
+ /** @description The ID of the generation */
+ generation_id: string;
+ };
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Generation found */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["LumaAgentsGeneration"];
+ };
+ };
+ /** @description Error */
+ default: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["LumaAgentsError"];
+ };
+ };
+ };
+ };
PixverseGenerateTextVideo: {
parameters: {
query?: never;
diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts
index 3a1e6c877d..cf64b26ec8 100644
--- a/packages/shared-frontend-utils/src/formatUtil.test.ts
+++ b/packages/shared-frontend-utils/src/formatUtil.test.ts
@@ -3,12 +3,17 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
+ formatLocalizedMediumDate,
+ formatLocalizedNumber,
+ getFilePathSeparatorVariants,
getFilenameDetails,
getMediaTypeFromFilename,
getPathDetails,
highlightQuery,
isCivitaiModelUrl,
+ isCivitaiUrl,
isPreviewableMediaType,
+ joinFilePath,
truncateFilename
} from './formatUtil'
@@ -83,9 +88,11 @@ describe('formatUtil', () => {
describe('video files', () => {
it('should identify video extensions correctly', () => {
expect(getMediaTypeFromFilename('video.mp4')).toBe('video')
+ expect(getMediaTypeFromFilename('apple.m4v')).toBe('video')
expect(getMediaTypeFromFilename('clip.webm')).toBe('video')
expect(getMediaTypeFromFilename('movie.mov')).toBe('video')
expect(getMediaTypeFromFilename('film.avi')).toBe('video')
+ expect(getMediaTypeFromFilename('episode.mkv')).toBe('video')
})
})
@@ -299,6 +306,42 @@ describe('formatUtil', () => {
})
})
+ describe('joinFilePath', () => {
+ it('joins subfolder and filename with normalized slash separators', () => {
+ expect(joinFilePath('nested\\folder', 'child\\file.png')).toBe(
+ 'nested/folder/child/file.png'
+ )
+ })
+
+ it('trims boundary separators without changing the filename body', () => {
+ expect(joinFilePath('/nested/folder/', '/file.png')).toBe(
+ 'nested/folder/file.png'
+ )
+ })
+
+ it('returns the normalized filename when no subfolder is provided', () => {
+ expect(joinFilePath('', 'nested\\file.png')).toBe('nested/file.png')
+ })
+
+ it('returns the normalized subfolder without a trailing slash when no filename is provided', () => {
+ expect(joinFilePath('nested\\folder', '')).toBe('nested/folder')
+ expect(joinFilePath('nested\\folder', null)).toBe('nested/folder')
+ })
+ })
+
+ describe('getFilePathSeparatorVariants', () => {
+ it('returns slash and backslash variants for nested paths', () => {
+ expect(getFilePathSeparatorVariants('nested\\folder/file.png')).toEqual([
+ 'nested/folder/file.png',
+ 'nested\\folder\\file.png'
+ ])
+ })
+
+ it('returns a single value when no separator is present', () => {
+ expect(getFilePathSeparatorVariants('file.png')).toEqual(['file.png'])
+ })
+ })
+
describe('appendWorkflowJsonExt', () => {
it('appends .app.json when isApp is true', () => {
expect(appendWorkflowJsonExt('test', true)).toBe('test.app.json')
@@ -381,6 +424,19 @@ describe('formatUtil', () => {
})
})
+ describe('isCivitaiUrl', () => {
+ it.for([
+ { url: 'https://civitai.com/models/123', expected: true },
+ { url: 'https://civitai.red/models/123', expected: true },
+ { url: 'https://sub.civitai.com/models/123', expected: true },
+ { url: 'https://sub.civitai.red/models/123', expected: true },
+ { url: 'https://example.com/model', expected: false },
+ { url: 'not-a-url', expected: false }
+ ])('$url → $expected', ({ url, expected }) => {
+ expect(isCivitaiUrl(url)).toBe(expected)
+ })
+ })
+
describe('isCivitaiModelUrl', () => {
it('recognizes civitai.red model URLs', () => {
expect(
@@ -388,4 +444,34 @@ describe('formatUtil', () => {
).toBe(true)
})
})
+
+ describe('formatLocalizedNumber', () => {
+ it('formats numbers using the given locale', () => {
+ expect(formatLocalizedNumber(2618646, 'en')).toBe('2,618,646')
+ expect(formatLocalizedNumber(2618646, 'de')).toBe('2.618.646')
+ })
+
+ it('returns an em-dash for undefined / NaN / Infinity', () => {
+ expect(formatLocalizedNumber(undefined, 'en')).toBe('—')
+ expect(formatLocalizedNumber(Number.NaN, 'en')).toBe('—')
+ expect(formatLocalizedNumber(Number.POSITIVE_INFINITY, 'en')).toBe('—')
+ })
+
+ it('formats zero as "0"', () => {
+ expect(formatLocalizedNumber(0, 'en')).toBe('0')
+ })
+ })
+
+ describe('formatLocalizedMediumDate', () => {
+ it('formats an ISO date with the medium style', () => {
+ expect(formatLocalizedMediumDate('2026-04-19T00:00:00Z', 'en')).toMatch(
+ /Apr \d{1,2}, 2026/
+ )
+ })
+
+ it('returns an em-dash for undefined or unparseable input', () => {
+ expect(formatLocalizedMediumDate(undefined, 'en')).toBe('—')
+ expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
+ })
+ })
})
diff --git a/packages/shared-frontend-utils/src/formatUtil.ts b/packages/shared-frontend-utils/src/formatUtil.ts
index 3e52190092..99b6bc79f5 100644
--- a/packages/shared-frontend-utils/src/formatUtil.ts
+++ b/packages/shared-frontend-utils/src/formatUtil.ts
@@ -256,6 +256,31 @@ export function isValidUrl(url: string): boolean {
}
}
+export function joinFilePath(
+ subfolder: string | null | undefined,
+ filename: string | null | undefined
+): string {
+ const normalizedSubfolder = normalizeFilePathSeparators(
+ subfolder ?? ''
+ ).replace(/^\/+|\/+$/g, '')
+ const normalizedFilename = normalizeFilePathSeparators(
+ filename ?? ''
+ ).replace(/^\/+/g, '')
+ if (!normalizedSubfolder) return normalizedFilename
+ if (!normalizedFilename) return normalizedSubfolder
+ return `${normalizedSubfolder}/${normalizedFilename}`
+}
+
+export function getFilePathSeparatorVariants(filepath: string): string[] {
+ const slashPath = normalizeFilePathSeparators(filepath)
+ const backslashPath = slashPath.replace(/\//g, '\\')
+ return slashPath === backslashPath ? [slashPath] : [slashPath, backslashPath]
+}
+
+function normalizeFilePathSeparators(filepath: string): string {
+ return filepath.replace(/[\\/]+/g, '/')
+}
+
/**
* Parses a filepath into its filename and subfolder components.
*
@@ -274,8 +299,7 @@ export function parseFilePath(filepath: string): {
} {
if (!filepath?.trim()) return { filename: '', subfolder: '' }
- const normalizedPath = filepath
- .replace(/[\\/]+/g, '/') // Normalize path separators
+ const normalizedPath = normalizeFilePathSeparators(filepath)
.replace(/^\//, '') // Remove leading slash
.replace(/\/$/, '') // Remove trailing slash
@@ -355,6 +379,22 @@ export const generateUUID = (): string => {
})
}
+const isCivitaiHost = (hostname: string): boolean =>
+ hostname === 'civitai.com' ||
+ hostname.endsWith('.civitai.com') ||
+ hostname === 'civitai.red' ||
+ hostname.endsWith('.civitai.red')
+
+/**
+ * Checks if a URL belongs to any Civitai domain (civitai.com or civitai.red).
+ * Use this for source-name detection; use `isCivitaiModelUrl` when the URL
+ * must also match a specific model API path format.
+ */
+export const isCivitaiUrl = (url: string): boolean => {
+ if (!isValidUrl(url)) return false
+ return isCivitaiHost(new URL(url).hostname.toLowerCase())
+}
+
/**
* Checks if a URL is a Civitai model URL
* @example
@@ -367,17 +407,9 @@ export const isCivitaiModelUrl = (url: string): boolean => {
if (!isValidUrl(url)) return false
const urlObj = new URL(url)
- const hostname = urlObj.hostname.toLowerCase()
- const isCivitaiHost =
- hostname === 'civitai.com' ||
- hostname.endsWith('.civitai.com') ||
- hostname === 'civitai.red' ||
- hostname.endsWith('.civitai.red')
- if (!isCivitaiHost) {
- return false
- }
- const pathname = urlObj.pathname
+ if (!isCivitaiHost(urlObj.hostname.toLowerCase())) return false
+ const pathname = urlObj.pathname
return (
/^\/api\/download\/models\/(\d+)$/.test(pathname) ||
/^\/api\/v1\/models\/(\d+)$/.test(pathname) ||
@@ -557,7 +589,7 @@ const IMAGE_EXTENSIONS = [
'tiff',
'svg'
] as const
-const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'avi'] as const
+const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz'] as const
const TEXT_EXTENSIONS = [
@@ -651,3 +683,32 @@ export function formatTime(seconds: number): string {
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
+
+/**
+ * Format a number with the given BCP-47 locale.
+ * Returns an em-dash for non-numeric, NaN, or infinite inputs.
+ */
+export function formatLocalizedNumber(
+ value: number | undefined,
+ locale: string
+): string {
+ if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
+ return new Intl.NumberFormat(locale).format(value)
+}
+
+/**
+ * Format an ISO 8601 date string with the given BCP-47 locale using the
+ * `medium` date style (e.g. "Apr 19, 2026"). Returns an em-dash for missing
+ * or unparseable inputs.
+ */
+export function formatLocalizedMediumDate(
+ value: string | undefined,
+ locale: string
+): string {
+ if (!value) return '—'
+ const timestamp = Date.parse(value)
+ if (Number.isNaN(timestamp)) return '—'
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(
+ timestamp
+ )
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3bf369cc14..6ac5b0dca6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -160,8 +160,8 @@ catalogs:
specifier: ^7.7.0
version: 7.7.0
'@types/three':
- specifier: ^0.169.0
- version: 0.169.0
+ specifier: ^0.170.0
+ version: 0.170.0
'@vee-validate/zod':
specifier: ^4.15.1
version: 4.15.1
@@ -339,6 +339,9 @@ catalogs:
tailwindcss-primeui:
specifier: ^0.6.1
version: 0.6.1
+ three:
+ specifier: ^0.170.0
+ version: 0.170.0
tsx:
specifier: ^4.15.6
version: 4.19.4
@@ -434,6 +437,9 @@ importers:
'@comfyorg/design-system':
specifier: workspace:*
version: link:packages/design-system
+ '@comfyorg/object-info-parser':
+ specifier: workspace:*
+ version: link:packages/object-info-parser
'@comfyorg/registry-types':
specifier: workspace:*
version: link:packages/registry-types
@@ -698,7 +704,7 @@ importers:
version: 7.7.0
'@types/three':
specifier: 'catalog:'
- version: 0.169.0
+ version: 0.170.0
'@vitejs/plugin-vue':
specifier: 'catalog:'
version: 6.0.3(vite@8.0.0(@types/node@24.10.4)(esbuild@0.27.3)(jiti@2.6.1)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2))(vue@3.5.13(typescript@5.9.3))
@@ -943,6 +949,12 @@ importers:
'@comfyorg/design-system':
specifier: workspace:*
version: link:../../packages/design-system
+ '@comfyorg/object-info-parser':
+ specifier: workspace:*
+ version: link:../../packages/object-info-parser
+ '@comfyorg/shared-frontend-utils':
+ specifier: workspace:*
+ version: link:../../packages/shared-frontend-utils
'@comfyorg/tailwind-utils':
specifier: workspace:*
version: link:../../packages/tailwind-utils
@@ -964,6 +976,9 @@ importers:
posthog-js:
specifier: 'catalog:'
version: 1.358.1
+ three:
+ specifier: 'catalog:'
+ version: 0.170.0
vue:
specifier: 'catalog:'
version: 3.5.13(typescript@5.9.3)
@@ -1034,6 +1049,22 @@ importers:
specifier: 0.93.0
version: 0.93.0(magicast@0.5.1)(typescript@5.9.3)
+ packages/object-info-parser:
+ dependencies:
+ zod:
+ specifier: 'catalog:'
+ version: 3.25.76
+ zod-validation-error:
+ specifier: 'catalog:'
+ version: 3.3.0(zod@3.25.76)
+ devDependencies:
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
+ vitest:
+ specifier: 'catalog:'
+ version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
+
packages/registry-types: {}
packages/shared-frontend-utils:
@@ -4508,8 +4539,8 @@ packages:
'@types/stats.js@0.17.3':
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
- '@types/three@0.169.0':
- resolution: {integrity: sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==}
+ '@types/three@0.170.0':
+ resolution: {integrity: sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==}
'@types/tough-cookie@4.0.5':
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
@@ -9883,8 +9914,8 @@ packages:
vue-component-type-helpers@3.2.6:
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
- vue-component-type-helpers@3.2.7:
- resolution: {integrity: sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==}
+ vue-component-type-helpers@3.2.8:
+ resolution: {integrity: sha512-9689efAXhN/EV86plgkL/XFiJSXhGtWPG6JDboZ+QnjlUWUUQrQ0ILKQtw4iQsuwIwu5k6Aw+JnehDe7161e7A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -13405,7 +13436,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.13(typescript@5.9.3)
- vue-component-type-helpers: 3.2.7
+ vue-component-type-helpers: 3.2.8
'@swc/helpers@0.5.17':
dependencies:
@@ -13834,7 +13865,7 @@ snapshots:
'@types/stats.js@0.17.3': {}
- '@types/three@0.169.0':
+ '@types/three@0.170.0':
dependencies:
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.3
@@ -14189,7 +14220,7 @@ snapshots:
sirv: 3.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
- vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@24.10.4)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
+ vitest: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(@vitest/ui@4.0.16)(esbuild@0.27.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.4.0)(terser@5.39.2)(tsx@4.19.4)(yaml@2.8.2)
'@vitest/utils@3.2.4':
dependencies:
@@ -20530,7 +20561,7 @@ snapshots:
vue-component-type-helpers@3.2.6: {}
- vue-component-type-helpers@3.2.7: {}
+ vue-component-type-helpers@3.2.8: {}
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
dependencies:
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 2059d90ab8..7eb19d6d70 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -54,7 +54,7 @@ catalog:
'@types/jsdom': ^21.1.7
'@types/node': ^24.1.0
'@types/semver': ^7.7.0
- '@types/three': ^0.169.0
+ '@types/three': ^0.170.0
'@vee-validate/zod': ^4.15.1
'@vercel/analytics': ^2.0.1
'@vitejs/plugin-vue': ^6.0.0
@@ -113,6 +113,7 @@ catalog:
storybook: ^10.2.10
stylelint: ^16.26.1
tailwindcss: ^4.2.0
+ three: ^0.170.0
tailwindcss-primeui: ^0.6.1
tsx: ^4.15.6
tw-animate-css: ^1.3.8
diff --git a/public/assets/sorted-custom-node-map.json b/public/assets/sorted-custom-node-map.json
index 3bd0889fe1..87cea332c8 100644
--- a/public/assets/sorted-custom-node-map.json
+++ b/public/assets/sorted-custom-node-map.json
@@ -3,6 +3,7 @@
"LoadImage": 3474,
"CLIPTextEncode": 2435,
"SaveImage": 1762,
+ "SaveImageAdvanced": 1762,
"VAEDecode": 1754,
"KSampler": 1511,
"CheckpointLoaderSimple": 1293,
diff --git a/scripts/generate-embedded-metadata-test-files.py b/scripts/generate-embedded-metadata-test-files.py
index 2b57e296d3..b66546ca28 100644
--- a/scripts/generate-embedded-metadata-test-files.py
+++ b/scripts/generate-embedded-metadata-test-files.py
@@ -19,6 +19,7 @@ import subprocess
import av
from PIL import Image
+from PIL.PngImagePlugin import PngInfo
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
@@ -115,6 +116,15 @@ def generate_av_fixture(
report(name)
+def generate_png():
+ img = make_1x1_image()
+ info = PngInfo()
+ info.add_text('workflow', WORKFLOW_JSON)
+ info.add_text('prompt', PROMPT_JSON)
+ img.save(out('with_metadata.png'), 'PNG', pnginfo=info)
+ report('with_metadata.png')
+
+
def generate_webp():
img = make_1x1_image()
exif = build_exif_bytes()
@@ -167,6 +177,7 @@ def generate_webm():
if __name__ == '__main__':
print('Generating fixtures...')
+ generate_png()
generate_webp()
generate_avif()
generate_flac()
diff --git a/src/base/webviewDetection.test.ts b/src/base/webviewDetection.test.ts
new file mode 100644
index 0000000000..c7faab6854
--- /dev/null
+++ b/src/base/webviewDetection.test.ts
@@ -0,0 +1,119 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { isEmbeddedWebView } from '@/base/webviewDetection'
+
+describe('isEmbeddedWebView', () => {
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ describe('Android WebView', () => {
+ it('detects Android WebView with wv token', () => {
+ const ua =
+ 'Mozilla/5.0 (Linux; Android 13; SM-G991B; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/120.0.0.0 Mobile Safari/537.36'
+ expect(isEmbeddedWebView(ua)).toBe(true)
+ })
+
+ it('does not flag regular Chrome on Android', () => {
+ const ua =
+ 'Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'
+ expect(isEmbeddedWebView(ua)).toBe(false)
+ })
+ })
+
+ describe('iOS WKWebView', () => {
+ it('detects iOS WKWebView (AppleWebKit without Safari/)', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148'
+ expect(isEmbeddedWebView(ua)).toBe(true)
+ })
+
+ it('does not flag regular Safari on iOS', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
+ expect(isEmbeddedWebView(ua)).toBe(false)
+ })
+
+ it('does not flag Chrome on iOS (CriOS)', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/120.0.0.0 Mobile/15E148'
+ expect(isEmbeddedWebView(ua)).toBe(false)
+ })
+
+ it('does not flag Firefox on iOS (FxiOS)', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/120.0 Mobile/15E148'
+ expect(isEmbeddedWebView(ua)).toBe(false)
+ })
+ })
+
+ describe('social app in-app browsers', () => {
+ it('detects Facebook (FBAN)', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 [FBAN/FBIOS;FBAV/400.0]'
+ expect(isEmbeddedWebView(ua)).toBe(true)
+ })
+
+ it('detects Instagram', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Instagram 300.0'
+ expect(isEmbeddedWebView(ua)).toBe(true)
+ })
+
+ it('detects TikTok', () => {
+ const ua =
+ 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36 TikTok/30.0'
+ expect(isEmbeddedWebView(ua)).toBe(true)
+ })
+
+ it('detects Line', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Line/13.0'
+ expect(isEmbeddedWebView(ua)).toBe(true)
+ })
+
+ it('detects Snapchat', () => {
+ const ua =
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Snapchat/12.0'
+ expect(isEmbeddedWebView(ua)).toBe(true)
+ })
+ })
+
+ describe('regular desktop browsers', () => {
+ it('does not flag Chrome desktop', () => {
+ const ua =
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
+ expect(isEmbeddedWebView(ua)).toBe(false)
+ })
+
+ it('does not flag Firefox desktop', () => {
+ const ua =
+ 'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0'
+ expect(isEmbeddedWebView(ua)).toBe(false)
+ })
+
+ it('does not flag Safari desktop', () => {
+ const ua =
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'
+ expect(isEmbeddedWebView(ua)).toBe(false)
+ })
+ })
+
+ describe('edge cases', () => {
+ it('handles empty string', () => {
+ expect(isEmbeddedWebView('')).toBe(false)
+ })
+ })
+
+ describe('JS bridge detection', () => {
+ it('detects webkit.messageHandlers bridge', () => {
+ vi.stubGlobal('webkit', { messageHandlers: {} })
+ expect(isEmbeddedWebView('')).toBe(true)
+ })
+
+ it('detects ReactNativeWebView bridge', () => {
+ vi.stubGlobal('ReactNativeWebView', { postMessage: vi.fn() })
+ expect(isEmbeddedWebView('')).toBe(true)
+ })
+ })
+})
diff --git a/src/base/webviewDetection.ts b/src/base/webviewDetection.ts
new file mode 100644
index 0000000000..4d56118409
--- /dev/null
+++ b/src/base/webviewDetection.ts
@@ -0,0 +1,72 @@
+/**
+ * Detects whether the app is running inside an embedded webview.
+ *
+ * Google blocks OAuth via `signInWithPopup` in embedded webviews,
+ * returning a 403 `disallowed_useragent` error (policy since 2021).
+ * This utility is used to hide the Google SSO button in those contexts.
+ *
+ * Detection covers:
+ * • Android WebView (`wv` token in UA)
+ * • iOS WKWebView (has `AppleWebKit` but lacks `Safari/`)
+ * • Social app in-app browsers (Facebook, Instagram, TikTok, etc.)
+ * • JS bridge objects (`window.webkit.messageHandlers`, `ReactNativeWebView`)
+ */
+
+const SOCIAL_APP_PATTERNS =
+ /FBAN|FBAV|Instagram|Line\/|Snapchat|TikTok|musical_ly/i
+
+function isAndroidWebView(ua: string): boolean {
+ return /\bwv\b/.test(ua) && /Android/.test(ua)
+}
+
+function isIOSWebView(ua: string): boolean {
+ if (!/AppleWebKit/i.test(ua)) return false
+ if (/Safari\//i.test(ua)) return false
+ if (/CriOS|FxiOS|OPiOS|EdgiOS/i.test(ua)) return false
+ return true
+}
+
+function isSocialAppBrowser(ua: string): boolean {
+ return SOCIAL_APP_PATTERNS.test(ua)
+}
+
+function hasWebViewBridge(): boolean {
+ try {
+ const win = globalThis as Record
+ if (
+ typeof win.webkit === 'object' &&
+ win.webkit !== null &&
+ typeof (win.webkit as Record).messageHandlers ===
+ 'object'
+ ) {
+ return true
+ }
+ if (win.ReactNativeWebView != null) return true
+ } catch {
+ // Access to bridge objects may throw in sandboxed contexts
+ }
+ return false
+}
+
+export function isEmbeddedWebView(ua: string = navigator.userAgent): boolean {
+ if (isSocialAppBrowser(ua)) return true
+ if (isAndroidWebView(ua)) return true
+ if (isIOSWebView(ua)) return true
+ if (hasWebViewBridge()) return true
+ return false
+}
+
+/**
+ * Reason why Google SSO is blocked in the current environment, or `null` if it
+ * is available. Modeled as a discriminated string so call sites read as
+ * "if blocked, here's why" rather than an opaque boolean. Extend this union
+ * (e.g. `'unauthorized-host'`) as new blocking conditions are detected.
+ */
+type GoogleSsoBlockedReason = 'embedded-webview' | null
+
+export function getGoogleSsoBlockedReason(
+ ua: string = navigator.userAgent
+): GoogleSsoBlockedReason {
+ if (isEmbeddedWebView(ua)) return 'embedded-webview'
+ return null
+}
diff --git a/src/components/common/Badge.test.ts b/src/components/common/Badge.test.ts
index 86507cce53..a3804cc12e 100644
--- a/src/components/common/Badge.test.ts
+++ b/src/components/common/Badge.test.ts
@@ -42,7 +42,7 @@ describe('Badge', () => {
})
describe('twMerge preserves color alongside text-3xs font size', () => {
- it.each([
+ it.for([
['default', 'text-white'],
['secondary', 'text-white'],
['warn', 'text-white'],
@@ -50,7 +50,7 @@ describe('Badge', () => {
['contrast', 'text-base-background']
] as const)(
'%s severity retains its text color class',
- (severity, expectedColor) => {
+ ([severity, expectedColor]) => {
const classes = badgeVariants({ severity, variant: 'label' })
expect(classes).toContain(expectedColor)
expect(classes).toContain('text-3xs')
diff --git a/src/components/common/CustomizationDialog.test.ts b/src/components/common/CustomizationDialog.test.ts
new file mode 100644
index 0000000000..bb6f79fd1f
--- /dev/null
+++ b/src/components/common/CustomizationDialog.test.ts
@@ -0,0 +1,104 @@
+import { render, screen } from '@testing-library/vue'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+import { createI18n } from 'vue-i18n'
+
+import CustomizationDialog from './CustomizationDialog.vue'
+
+const DEFAULT_ICON = 'pi-bookmark-fill'
+const DEFAULT_COLOR = '#a1a1aa'
+
+vi.mock('@/stores/nodeBookmarkStore', () => ({
+ useNodeBookmarkStore: () => ({
+ defaultBookmarkIcon: DEFAULT_ICON,
+ defaultBookmarkColor: DEFAULT_COLOR,
+ bookmarksCustomization: {}
+ })
+}))
+
+vi.mock('primevue/dialog', () => ({
+ default: {
+ name: 'Dialog',
+ template: '
',
+ props: ['visible']
+ }
+}))
+
+vi.mock('primevue/selectbutton', () => ({
+ default: {
+ name: 'SelectButton',
+ template: '',
+ props: ['modelValue', 'options']
+ }
+}))
+
+vi.mock('primevue/divider', () => ({
+ default: { name: 'Divider', template: '
' }
+}))
+
+vi.mock('@/components/common/ColorCustomizationSelector.vue', () => ({
+ default: {
+ name: 'ColorCustomizationSelector',
+ template: '',
+ props: ['modelValue', 'colorOptions']
+ }
+}))
+
+vi.mock('@/components/ui/button/Button.vue', () => ({
+ default: {
+ name: 'Button',
+ template: ``,
+ emits: ['click']
+ }
+}))
+
+const i18n = createI18n({ legacy: false, locale: 'en', messages: { en: {} } })
+
+function renderDialog(extraProps: Record = {}) {
+ const onConfirm = vi.fn()
+ render(CustomizationDialog, {
+ global: { plugins: [i18n] },
+ props: { modelValue: true, onConfirm, ...extraProps }
+ })
+ return { onConfirm }
+}
+
+describe('CustomizationDialog', () => {
+ describe('confirmCustomization', () => {
+ it('emits confirm with default icon and color when no initial values provided', async () => {
+ const user = userEvent.setup()
+ const { onConfirm } = renderDialog()
+
+ await user.click(screen.getByText('g.confirm'))
+
+ expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, DEFAULT_COLOR)
+ })
+
+ it('emits confirm with matching initialIcon when provided', async () => {
+ const user = userEvent.setup()
+ const { onConfirm } = renderDialog({ initialIcon: 'pi-star' })
+
+ await user.click(screen.getByText('g.confirm'))
+
+ expect(onConfirm).toHaveBeenCalledWith('pi-star', DEFAULT_COLOR)
+ })
+
+ it('falls back to default icon when initialIcon does not match any option', async () => {
+ const user = userEvent.setup()
+ const { onConfirm } = renderDialog({ initialIcon: 'pi-nonexistent' })
+
+ await user.click(screen.getByText('g.confirm'))
+
+ expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, DEFAULT_COLOR)
+ })
+
+ it('emits confirm with initialColor when provided', async () => {
+ const user = userEvent.setup()
+ const { onConfirm } = renderDialog({ initialColor: '#007bff' })
+
+ await user.click(screen.getByText('g.confirm'))
+
+ expect(onConfirm).toHaveBeenCalledWith(DEFAULT_ICON, '#007bff')
+ })
+ })
+})
diff --git a/src/components/common/CustomizationDialog.vue b/src/components/common/CustomizationDialog.vue
index f5df6d84d7..50b707be19 100644
--- a/src/components/common/CustomizationDialog.vue
+++ b/src/components/common/CustomizationDialog.vue
@@ -94,17 +94,15 @@ const defaultIcon = iconOptions.find(
(option) => option.value === nodeBookmarkStore.defaultBookmarkIcon
)
-// @ts-expect-error fixme ts strict error
-const selectedIcon = ref<{ name: string; value: string }>(defaultIcon)
+const selectedIcon = ref(defaultIcon ?? iconOptions[0])
const finalColor = ref(
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
)
const resetCustomization = () => {
- // @ts-expect-error fixme ts strict error
selectedIcon.value =
- iconOptions.find((option) => option.value === props.initialIcon) ||
- defaultIcon
+ iconOptions.find((option) => option.value === props.initialIcon) ??
+ iconOptions[0]
finalColor.value =
props.initialColor || nodeBookmarkStore.defaultBookmarkColor
}
diff --git a/src/components/common/FormItem.vue b/src/components/common/FormItem.vue
index c3c76afe84..3a5bb34020 100644
--- a/src/components/common/FormItem.vue
+++ b/src/components/common/FormItem.vue
@@ -1,10 +1,10 @@
-