mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-17 05:01:02 +00:00
Compare commits
4 Commits
test/edit-
...
feat/hub-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
589f7b434e | ||
|
|
97b160113c | ||
|
|
dcf4bb3534 | ||
|
|
0e7c4c1426 |
@@ -12,9 +12,9 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
vi.mock('@/components/templates/thumbnails/LazyMedia.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
name: 'LazyMedia',
|
||||
template:
|
||||
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle']
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseThumbnail :is-hovered="isHovered">
|
||||
<LazyImage
|
||||
<LazyMedia
|
||||
:src="baseImageSrc"
|
||||
:alt="alt"
|
||||
:image-class="
|
||||
@@ -10,7 +10,7 @@
|
||||
"
|
||||
/>
|
||||
<div ref="containerRef" class="absolute inset-0">
|
||||
<LazyImage
|
||||
<LazyMedia
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
:image-class="
|
||||
@@ -37,8 +37,9 @@
|
||||
import { useMouseInElement } from '@vueuse/core'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
import LazyMedia from '@/components/templates/thumbnails/LazyMedia.vue'
|
||||
import { isVideoSrc } from '@/platform/workflow/templates/utils/hubAssetUrl'
|
||||
|
||||
const SLIDER_START_POSITION = 50
|
||||
|
||||
@@ -52,6 +53,8 @@ const { baseImageSrc, overlayImageSrc, isHovered, isVideo } = defineProps<{
|
||||
|
||||
const isVideoType =
|
||||
isVideo ||
|
||||
isVideoSrc(baseImageSrc) ||
|
||||
isVideoSrc(overlayImageSrc) ||
|
||||
baseImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
overlayImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
false
|
||||
|
||||
@@ -11,9 +11,9 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
vi.mock('@/components/templates/thumbnails/LazyMedia.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
name: 'LazyMedia',
|
||||
template:
|
||||
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle']
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<BaseThumbnail :hover-zoom="hoverZoom" :is-hovered="isHovered">
|
||||
<LazyImage
|
||||
<LazyMedia
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:image-class="[
|
||||
@@ -17,8 +17,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
import LazyMedia from '@/components/templates/thumbnails/LazyMedia.vue'
|
||||
import { isVideoSrc } from '@/platform/workflow/templates/utils/hubAssetUrl'
|
||||
|
||||
const { src, isVideo } = defineProps<{
|
||||
src: string
|
||||
@@ -28,5 +29,6 @@ const { src, isVideo } = defineProps<{
|
||||
isVideo?: boolean
|
||||
}>()
|
||||
|
||||
const isVideoType = isVideo ?? (src?.toLowerCase().endsWith('.webp') || false)
|
||||
const isVideoType =
|
||||
isVideo ?? (isVideoSrc(src) || src?.toLowerCase().endsWith('.webp') || false)
|
||||
</script>
|
||||
|
||||
@@ -11,9 +11,9 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
vi.mock('@/components/templates/thumbnails/LazyMedia.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
name: 'LazyMedia',
|
||||
template:
|
||||
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle']
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
<BaseThumbnail :is-hovered="isHovered">
|
||||
<div class="relative size-full">
|
||||
<div class="absolute inset-0">
|
||||
<LazyImage
|
||||
<LazyMedia
|
||||
:src="baseImageSrc"
|
||||
:alt="alt"
|
||||
:image-class="baseImageClass"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute inset-0 z-10">
|
||||
<LazyImage
|
||||
<LazyMedia
|
||||
:src="overlayImageSrc"
|
||||
:alt="alt"
|
||||
:image-class="overlayImageClass"
|
||||
@@ -22,8 +22,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
import LazyMedia from '@/components/templates/thumbnails/LazyMedia.vue'
|
||||
import { isVideoSrc } from '@/platform/workflow/templates/utils/hubAssetUrl'
|
||||
|
||||
const { baseImageSrc, overlayImageSrc, isVideo, isHovered } = defineProps<{
|
||||
baseImageSrc: string
|
||||
@@ -35,6 +36,8 @@ const { baseImageSrc, overlayImageSrc, isVideo, isHovered } = defineProps<{
|
||||
|
||||
const isVideoType =
|
||||
isVideo ||
|
||||
isVideoSrc(baseImageSrc) ||
|
||||
isVideoSrc(overlayImageSrc) ||
|
||||
baseImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
overlayImageSrc?.toLowerCase().endsWith('.webp') ||
|
||||
false
|
||||
|
||||
114
src/components/templates/thumbnails/LazyMedia.test.ts
Normal file
114
src/components/templates/thumbnails/LazyMedia.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import LazyMedia from '@/components/templates/thumbnails/LazyMedia.vue'
|
||||
|
||||
const intersectionCallbacks: Array<
|
||||
(entries: IntersectionObserverEntry[]) => void
|
||||
> = []
|
||||
|
||||
vi.mock('@/composables/useIntersectionObserver', () => ({
|
||||
useIntersectionObserver: (
|
||||
_ref: unknown,
|
||||
cb: (entries: IntersectionObserverEntry[]) => void
|
||||
) => {
|
||||
intersectionCallbacks.push(cb)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/components/common/LazyImage.vue', () => ({
|
||||
default: {
|
||||
name: 'LazyImage',
|
||||
template:
|
||||
'<img data-testid="lazy-image" :src="src" :alt="alt" :class="imageClass" />',
|
||||
props: ['src', 'alt', 'imageClass', 'imageStyle', 'rootMargin']
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('primevue/skeleton', () => ({
|
||||
default: {
|
||||
name: 'Skeleton',
|
||||
template: '<div data-testid="skeleton" />'
|
||||
}
|
||||
}))
|
||||
|
||||
function triggerIntersection(isIntersecting: boolean) {
|
||||
intersectionCallbacks.forEach((cb) =>
|
||||
cb([{ isIntersecting } as IntersectionObserverEntry])
|
||||
)
|
||||
}
|
||||
|
||||
describe('LazyMedia', () => {
|
||||
beforeEach(() => {
|
||||
intersectionCallbacks.length = 0
|
||||
})
|
||||
|
||||
it('renders LazyImage for non-video src', () => {
|
||||
render(LazyMedia, { props: { src: '/thumb.webp', alt: 'cover' } })
|
||||
expect(screen.getByTestId('lazy-image')).toHaveAttribute(
|
||||
'src',
|
||||
'/thumb.webp'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a <video> with Cloudflare poster for hub-asset .mp4 src', async () => {
|
||||
render(LazyMedia, {
|
||||
props: {
|
||||
src: 'https://comfy-hub-assets.comfy.org/uploads/clip.mp4',
|
||||
alt: 'video cover'
|
||||
}
|
||||
})
|
||||
|
||||
triggerIntersection(true)
|
||||
await nextTick()
|
||||
|
||||
const video = screen.getByTestId('lazy-video')
|
||||
expect(video).toHaveAttribute(
|
||||
'src',
|
||||
'https://comfy-hub-assets.comfy.org/uploads/clip.mp4'
|
||||
)
|
||||
expect(video).toHaveAttribute(
|
||||
'poster',
|
||||
'https://comfy-hub-assets.comfy.org/cdn-cgi/media/mode=frame,time=1s/uploads/clip.mp4'
|
||||
)
|
||||
expect(video).toHaveAttribute('autoplay')
|
||||
expect(video).toHaveAttribute('loop')
|
||||
expect(video).toHaveAttribute('muted')
|
||||
expect(video).toHaveAttribute('playsinline')
|
||||
})
|
||||
|
||||
it('falls back to the Cloudflare frame image when the video errors', async () => {
|
||||
render(LazyMedia, {
|
||||
props: {
|
||||
src: 'https://comfy-hub-assets.comfy.org/uploads/clip.mp4',
|
||||
alt: 'video cover'
|
||||
}
|
||||
})
|
||||
|
||||
triggerIntersection(true)
|
||||
await nextTick()
|
||||
|
||||
const video = screen.getByTestId('lazy-video')
|
||||
await fireEvent(video, new Event('error'))
|
||||
await nextTick()
|
||||
|
||||
expect(screen.queryByTestId('lazy-video')).toBeNull()
|
||||
expect(screen.getByTestId('lazy-image')).toHaveAttribute(
|
||||
'src',
|
||||
'https://comfy-hub-assets.comfy.org/cdn-cgi/media/mode=frame,time=1s/uploads/clip.mp4'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not mount the video element before intersection', () => {
|
||||
render(LazyMedia, {
|
||||
props: {
|
||||
src: 'https://comfy-hub-assets.comfy.org/uploads/clip.mp4',
|
||||
alt: 'video cover'
|
||||
}
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('lazy-video')).toBeNull()
|
||||
expect(screen.getByTestId('skeleton')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
87
src/components/templates/thumbnails/LazyMedia.vue
Normal file
87
src/components/templates/thumbnails/LazyMedia.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative flex size-full items-center justify-center overflow-hidden"
|
||||
:class="containerClass"
|
||||
>
|
||||
<template v-if="showVideo">
|
||||
<Skeleton
|
||||
v-if="!isVideoLoaded"
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="absolute inset-0"
|
||||
/>
|
||||
<video
|
||||
v-if="shouldLoad"
|
||||
data-testid="lazy-video"
|
||||
:src="src"
|
||||
:poster="frameUrl"
|
||||
:class="imageClass"
|
||||
:style="imageStyle"
|
||||
:aria-label="alt"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
@loadeddata="isVideoLoaded = true"
|
||||
@error="videoError = true"
|
||||
/>
|
||||
</template>
|
||||
<LazyImage
|
||||
v-else
|
||||
:src="fallbackSrc"
|
||||
:alt="alt"
|
||||
:image-class="imageClass"
|
||||
:image-style="imageStyle"
|
||||
:root-margin="rootMargin"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { StyleValue } from 'vue'
|
||||
|
||||
import LazyImage from '@/components/common/LazyImage.vue'
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import {
|
||||
getVideoFrameUrl,
|
||||
isVideoSrc
|
||||
} from '@/platform/workflow/templates/utils/hubAssetUrl'
|
||||
import type { ClassValue } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
src,
|
||||
alt = '',
|
||||
containerClass = '',
|
||||
imageClass = '',
|
||||
imageStyle,
|
||||
rootMargin = '300px'
|
||||
} = defineProps<{
|
||||
src: string
|
||||
alt?: string
|
||||
containerClass?: ClassValue
|
||||
imageClass?: ClassValue
|
||||
imageStyle?: StyleValue
|
||||
rootMargin?: string
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const shouldLoad = ref(false)
|
||||
const isVideoLoaded = ref(false)
|
||||
const videoError = ref(false)
|
||||
|
||||
const isVideo = computed(() => isVideoSrc(src))
|
||||
const frameUrl = computed(() => getVideoFrameUrl(src))
|
||||
const showVideo = computed(() => isVideo.value && !videoError.value)
|
||||
const fallbackSrc = computed(() => (isVideo.value ? frameUrl.value : src))
|
||||
|
||||
useIntersectionObserver(
|
||||
containerRef,
|
||||
(entries) => {
|
||||
shouldLoad.value = entries[0]?.isIntersecting ?? false
|
||||
},
|
||||
{ rootMargin, threshold: 0.1 }
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { HubWorkflowIndexEntry } from '../schemas/hubWorkflowIndexSchema'
|
||||
import {
|
||||
mapHubWorkflowIndexEntryToTemplate,
|
||||
mapHubWorkflowIndexToCategories
|
||||
} from './hubWorkflowIndexMapper'
|
||||
|
||||
const makeEntry = (
|
||||
overrides?: Partial<HubWorkflowIndexEntry>
|
||||
): HubWorkflowIndexEntry => ({
|
||||
name: 'sdxl_simple',
|
||||
title: 'SDXL Simple',
|
||||
status: 'approved',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('mapHubWorkflowIndexEntryToTemplate', () => {
|
||||
it('maps template metadata used by the selector dialog', () => {
|
||||
const result = mapHubWorkflowIndexEntryToTemplate(
|
||||
makeEntry({
|
||||
description: 'Starter SDXL workflow',
|
||||
tags: ['Image', 'Text to Image'],
|
||||
models: ['SDXL'],
|
||||
requiresCustomNodes: ['comfy-custom-pack'],
|
||||
thumbnailVariant: 'compareSlider',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
size: 1024,
|
||||
vram: 2048,
|
||||
openSource: true,
|
||||
tutorialUrl: 'https://docs.comfy.org/tutorials/sdxl',
|
||||
logos: [
|
||||
{
|
||||
provider: 'OpenAI',
|
||||
label: 'OpenAI',
|
||||
opacity: 0.7
|
||||
}
|
||||
],
|
||||
date: '2026-04-14',
|
||||
includeOnDistributions: ['cloud', 'desktop', 'unsupported'],
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
|
||||
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp',
|
||||
shareId: 'share-123',
|
||||
usage: 42,
|
||||
searchRank: 7,
|
||||
isEssential: true,
|
||||
useCase: 'Image generation',
|
||||
license: 'MIT'
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
name: 'sdxl_simple',
|
||||
title: 'SDXL Simple',
|
||||
description: 'Starter SDXL workflow',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
thumbnailVariant: 'compareSlider',
|
||||
isEssential: true,
|
||||
shareId: 'share-123',
|
||||
tags: ['Image', 'Text to Image'],
|
||||
models: ['SDXL'],
|
||||
date: '2026-04-14',
|
||||
useCase: 'Image generation',
|
||||
license: 'MIT',
|
||||
vram: 2048,
|
||||
size: 1024,
|
||||
openSource: true,
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
|
||||
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp',
|
||||
requiresCustomNodes: ['comfy-custom-pack'],
|
||||
searchRank: 7,
|
||||
usage: 42,
|
||||
includeOnDistributions: ['cloud', 'desktop'],
|
||||
logos: [{ provider: 'OpenAI', label: 'OpenAI', opacity: 0.7 }],
|
||||
tutorialUrl: 'https://docs.comfy.org/tutorials/sdxl'
|
||||
})
|
||||
})
|
||||
|
||||
it('infers video thumbnails from preview URLs', () => {
|
||||
const result = mapHubWorkflowIndexEntryToTemplate(
|
||||
makeEntry({
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
thumbnailUrl: 'https://cdn.example.com/preview.mp4'
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.mediaType).toBe('video')
|
||||
expect(result.mediaSubtype).toBe('mp4')
|
||||
})
|
||||
|
||||
it('drops invalid logo and distribution values', () => {
|
||||
const result = mapHubWorkflowIndexEntryToTemplate(
|
||||
makeEntry({
|
||||
logos: [
|
||||
{ provider: ['OpenAI', 'Runway'], gap: -4 },
|
||||
{ provider: 123 }
|
||||
] as Array<Record<string, unknown>>,
|
||||
includeOnDistributions: ['local', 'desktop', 'invalid']
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.logos).toEqual([{ provider: ['OpenAI', 'Runway'], gap: -4 }])
|
||||
expect(result.includeOnDistributions).toEqual(['local', 'desktop'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mapHubWorkflowIndexToCategories', () => {
|
||||
it('groups entries by section and sectionGroup into WorkflowTemplates', () => {
|
||||
const result = mapHubWorkflowIndexToCategories([
|
||||
makeEntry({
|
||||
name: 'img-template',
|
||||
title: 'Image Template',
|
||||
section: 'Image',
|
||||
sectionGroup: 'GENERATION TYPE'
|
||||
}),
|
||||
makeEntry({
|
||||
name: 'vid-template',
|
||||
title: 'Video Template',
|
||||
section: 'Video',
|
||||
sectionGroup: 'GENERATION TYPE'
|
||||
}),
|
||||
makeEntry({
|
||||
name: 'img-template-2',
|
||||
title: 'Image Template 2',
|
||||
section: 'Image',
|
||||
sectionGroup: 'GENERATION TYPE'
|
||||
})
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
|
||||
const imageCategory = result.find((c) => c.title === 'Image')!
|
||||
expect(imageCategory.moduleName).toBe('default')
|
||||
expect(imageCategory.category).toBe('GENERATION TYPE')
|
||||
expect(imageCategory.templates.map((t) => t.name)).toEqual([
|
||||
'img-template',
|
||||
'img-template-2'
|
||||
])
|
||||
|
||||
const videoCategory = result.find((c) => c.title === 'Video')!
|
||||
expect(videoCategory.moduleName).toBe('default')
|
||||
expect(videoCategory.category).toBe('GENERATION TYPE')
|
||||
expect(videoCategory.templates.map((t) => t.name)).toEqual(['vid-template'])
|
||||
})
|
||||
|
||||
it('falls back to a single "All" category when entries lack section metadata', () => {
|
||||
const result = mapHubWorkflowIndexToCategories([
|
||||
makeEntry({ name: 'template-a', title: 'Template A' }),
|
||||
makeEntry({ name: 'template-b', title: 'Template B' })
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].moduleName).toBe('default')
|
||||
expect(result[0].title).toBe('All')
|
||||
expect(result[0].templates.map((t) => t.name)).toEqual([
|
||||
'template-a',
|
||||
'template-b'
|
||||
])
|
||||
})
|
||||
|
||||
it('propagates isEssential from entries to categories', () => {
|
||||
const result = mapHubWorkflowIndexToCategories([
|
||||
makeEntry({
|
||||
name: 'essential-1',
|
||||
section: 'Getting Started',
|
||||
sectionGroup: 'GENERATION TYPE',
|
||||
isEssential: true
|
||||
}),
|
||||
makeEntry({
|
||||
name: 'non-essential',
|
||||
section: 'Getting Started',
|
||||
sectionGroup: 'GENERATION TYPE',
|
||||
isEssential: false
|
||||
})
|
||||
])
|
||||
|
||||
const category = result.find((c) => c.title === 'Getting Started')!
|
||||
expect(category.isEssential).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { TemplateIncludeOnDistributionEnum } from '../types/template'
|
||||
import type {
|
||||
LogoInfo,
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '../types/template'
|
||||
import type { HubWorkflowIndexEntry } from '../schemas/hubWorkflowIndexSchema'
|
||||
import { rewriteHubAssetUrl } from '../utils/hubAssetUrl'
|
||||
|
||||
const distributionValues = new Set(
|
||||
Object.values(TemplateIncludeOnDistributionEnum)
|
||||
)
|
||||
|
||||
function getPreviewExtension(url?: string): string | undefined {
|
||||
if (!url) return undefined
|
||||
|
||||
try {
|
||||
const { pathname } = new URL(url)
|
||||
const extension = pathname.split('.').pop()?.toLowerCase()
|
||||
return extension || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getPreviewMediaType(
|
||||
thumbnailUrl?: string,
|
||||
mediaType?: string
|
||||
): string | undefined {
|
||||
const extension = getPreviewExtension(thumbnailUrl)
|
||||
|
||||
if (extension && ['mp4', 'webm', 'mov'].includes(extension)) {
|
||||
return 'video'
|
||||
}
|
||||
|
||||
if (extension && ['png', 'jpg', 'jpeg', 'webp', 'gif'].includes(extension)) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
return mediaType
|
||||
}
|
||||
|
||||
function getPreviewMediaSubtype(
|
||||
thumbnailUrl?: string,
|
||||
mediaSubtype?: string
|
||||
): string {
|
||||
return getPreviewExtension(thumbnailUrl) ?? mediaSubtype ?? 'webp'
|
||||
}
|
||||
|
||||
function mapLogo(logo: Record<string, unknown>): LogoInfo | null {
|
||||
const provider = logo.provider
|
||||
|
||||
if (
|
||||
typeof provider !== 'string' &&
|
||||
!(
|
||||
Array.isArray(provider) &&
|
||||
provider.length > 0 &&
|
||||
provider.every((value) => typeof value === 'string')
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
...(typeof logo.label === 'string' ? { label: logo.label } : {}),
|
||||
...(typeof logo.gap === 'number' ? { gap: logo.gap } : {}),
|
||||
...(typeof logo.position === 'string' ? { position: logo.position } : {}),
|
||||
...(typeof logo.opacity === 'number' ? { opacity: logo.opacity } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function mapLogos(
|
||||
logos?: Array<Record<string, unknown>>
|
||||
): LogoInfo[] | undefined {
|
||||
const mapped = logos?.map(mapLogo).filter((logo): logo is LogoInfo => !!logo)
|
||||
return mapped?.length ? mapped : undefined
|
||||
}
|
||||
|
||||
function mapIncludeOnDistributions(
|
||||
includeOnDistributions?: string[]
|
||||
): TemplateIncludeOnDistributionEnum[] | undefined {
|
||||
const mapped = includeOnDistributions?.filter(
|
||||
(value): value is TemplateIncludeOnDistributionEnum =>
|
||||
distributionValues.has(value as TemplateIncludeOnDistributionEnum)
|
||||
)
|
||||
return mapped?.length ? mapped : undefined
|
||||
}
|
||||
|
||||
export function mapHubWorkflowIndexEntryToTemplate(
|
||||
entry: HubWorkflowIndexEntry
|
||||
): TemplateInfo {
|
||||
const thumbnailUrl = rewriteHubAssetUrl(entry.thumbnailUrl)
|
||||
const thumbnailComparisonUrl = rewriteHubAssetUrl(
|
||||
entry.thumbnailComparisonUrl
|
||||
)
|
||||
const tutorialUrl = rewriteHubAssetUrl(entry.tutorialUrl)
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
title: entry.title,
|
||||
description: entry.description ?? '',
|
||||
mediaType:
|
||||
getPreviewMediaType(entry.thumbnailUrl, entry.mediaType) ?? 'image',
|
||||
mediaSubtype: getPreviewMediaSubtype(
|
||||
entry.thumbnailUrl,
|
||||
entry.mediaSubtype
|
||||
),
|
||||
thumbnailVariant: entry.thumbnailVariant,
|
||||
isEssential: entry.isEssential,
|
||||
shareId: entry.shareId,
|
||||
tags: entry.tags,
|
||||
models: entry.models,
|
||||
date: entry.date,
|
||||
useCase: entry.useCase,
|
||||
license: entry.license,
|
||||
vram: entry.vram,
|
||||
size: entry.size,
|
||||
openSource: entry.openSource,
|
||||
thumbnailUrl,
|
||||
thumbnailComparisonUrl,
|
||||
tutorialUrl,
|
||||
requiresCustomNodes: entry.requiresCustomNodes,
|
||||
searchRank: entry.searchRank,
|
||||
usage: entry.usage,
|
||||
includeOnDistributions: mapIncludeOnDistributions(
|
||||
entry.includeOnDistributions
|
||||
),
|
||||
logos: mapLogos(entry.logos)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(hub-api): Pending BE spec confirmation — field names (section/sectionGroup) may change.
|
||||
export function mapHubWorkflowIndexToCategories(
|
||||
entries: HubWorkflowIndexEntry[]
|
||||
): WorkflowTemplates[] {
|
||||
const hasSections = entries.some((e) => e.section)
|
||||
|
||||
if (!hasSections) {
|
||||
return [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'All',
|
||||
templates: entries.map(mapHubWorkflowIndexEntryToTemplate)
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const sectionMap = new Map<
|
||||
string,
|
||||
{ entries: HubWorkflowIndexEntry[]; sectionGroup?: string }
|
||||
>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = entry.section ?? 'All'
|
||||
if (!sectionMap.has(key)) {
|
||||
sectionMap.set(key, { entries: [], sectionGroup: entry.sectionGroup })
|
||||
}
|
||||
sectionMap.get(key)!.entries.push(entry)
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
sectionMap,
|
||||
([section, { entries: sectionEntries, sectionGroup }]) => {
|
||||
const templates = sectionEntries.map(mapHubWorkflowIndexEntryToTemplate)
|
||||
return {
|
||||
moduleName: 'default',
|
||||
title: section,
|
||||
category: sectionGroup,
|
||||
isEssential: sectionEntries.some((e) => e.isEssential),
|
||||
templates
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -17,6 +17,15 @@ const preservedQueryMocks = vi.hoisted(() => ({
|
||||
clearPreservedQuery: vi.fn()
|
||||
}))
|
||||
|
||||
const distributionState = vi.hoisted(() => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
const workflowTemplateStoreMocks = vi.hoisted(() => ({
|
||||
getTemplateByName: vi.fn(),
|
||||
getTemplateByShareId: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock vue-router
|
||||
let mockQueryParams: Record<string, string | string[] | undefined> = {}
|
||||
const mockRouterReplace = vi.fn()
|
||||
@@ -35,6 +44,19 @@ vi.mock(
|
||||
() => preservedQueryMocks
|
||||
)
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distributionState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
|
||||
() => ({
|
||||
useWorkflowTemplatesStore: () => workflowTemplateStoreMocks
|
||||
})
|
||||
)
|
||||
|
||||
// Mock template workflows composable
|
||||
const mockLoadTemplates = vi.fn().mockResolvedValue(true)
|
||||
const mockLoadWorkflowTemplate = vi.fn().mockResolvedValue(true)
|
||||
@@ -85,6 +107,7 @@ describe('useTemplateUrlLoader', () => {
|
||||
vi.clearAllMocks()
|
||||
mockQueryParams = {}
|
||||
mockCanvasStore.linearMode = false
|
||||
distributionState.isCloud = false
|
||||
})
|
||||
|
||||
it('does not load template when no query param present', () => {
|
||||
@@ -242,6 +265,21 @@ describe('useTemplateUrlLoader', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves cloud template URLs by share id before loading', async () => {
|
||||
distributionState.isCloud = true
|
||||
mockQueryParams = { template: 'share-123' }
|
||||
workflowTemplateStoreMocks.getTemplateByName.mockReturnValue(undefined)
|
||||
workflowTemplateStoreMocks.getTemplateByShareId.mockReturnValue({
|
||||
name: 'sdxl_simple',
|
||||
sourceModule: 'hub'
|
||||
})
|
||||
|
||||
const { loadTemplateFromUrl } = useTemplateUrlLoader()
|
||||
await loadTemplateFromUrl()
|
||||
|
||||
expect(mockLoadWorkflowTemplate).toHaveBeenCalledWith('sdxl_simple', 'hub')
|
||||
})
|
||||
|
||||
it('removes template params from URL after successful load', async () => {
|
||||
mockQueryParams = {
|
||||
template: 'flux_simple',
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useToast } from 'primevue/usetoast'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { clearPreservedQuery } from '@/platform/navigation/preservedQueryManager'
|
||||
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
@@ -108,9 +110,24 @@ export function useTemplateUrlLoader() {
|
||||
try {
|
||||
await templateWorkflows.loadTemplates()
|
||||
|
||||
let resolvedTemplate = templateParam
|
||||
let resolvedSource = sourceParam
|
||||
|
||||
if (isCloud) {
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const matchedTemplate =
|
||||
workflowTemplatesStore.getTemplateByName(templateParam) ??
|
||||
workflowTemplatesStore.getTemplateByShareId(templateParam)
|
||||
|
||||
if (matchedTemplate) {
|
||||
resolvedTemplate = matchedTemplate.name
|
||||
resolvedSource = matchedTemplate.sourceModule ?? resolvedSource
|
||||
}
|
||||
}
|
||||
|
||||
const success = await templateWorkflows.loadWorkflowTemplate(
|
||||
templateParam,
|
||||
sourceParam
|
||||
resolvedTemplate,
|
||||
resolvedSource
|
||||
)
|
||||
|
||||
if (!success) {
|
||||
|
||||
@@ -15,6 +15,16 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
const distributionState = vi.hoisted(() => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distributionState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the API
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
@@ -49,6 +59,14 @@ vi.mock('@/stores/dialogStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockGetSharedWorkflow = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/services/workflowShareService', () => ({
|
||||
useWorkflowShareService: () => ({
|
||||
getSharedWorkflow: mockGetSharedWorkflow
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
|
||||
@@ -58,9 +76,20 @@ describe('useTemplateWorkflows', () => {
|
||||
let mockWorkflowTemplatesStore: MockWorkflowTemplatesStore
|
||||
|
||||
beforeEach(() => {
|
||||
distributionState.isCloud = false
|
||||
mockWorkflowTemplatesStore = {
|
||||
isLoaded: false,
|
||||
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
|
||||
getTemplateByName: vi.fn((name: string) =>
|
||||
name === 'template1'
|
||||
? {
|
||||
name: 'template1',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Template 1 description'
|
||||
}
|
||||
: undefined
|
||||
),
|
||||
groupedTemplates: [
|
||||
{
|
||||
label: 'ComfyUI Examples',
|
||||
@@ -115,6 +144,16 @@ describe('useTemplateWorkflows', () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ workflow: 'data' })
|
||||
} as Partial<Response> as Response)
|
||||
|
||||
mockGetSharedWorkflow.mockResolvedValue({
|
||||
shareId: 'share-123',
|
||||
workflowId: 'workflow-123',
|
||||
name: 'Shared Template',
|
||||
listed: true,
|
||||
publishedAt: null,
|
||||
workflowJson: { workflow: 'shared' },
|
||||
assets: []
|
||||
})
|
||||
})
|
||||
|
||||
it('should load templates from store', async () => {
|
||||
@@ -178,6 +217,25 @@ describe('useTemplateWorkflows', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefer absolute thumbnail URLs when provided', () => {
|
||||
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
|
||||
const template = {
|
||||
name: 'hub-template',
|
||||
mediaSubtype: 'webp',
|
||||
mediaType: 'image',
|
||||
description: 'Hub template',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.webp',
|
||||
thumbnailComparisonUrl: 'https://cdn.example.com/compare.webp'
|
||||
}
|
||||
|
||||
expect(getTemplateThumbnailUrl(template, 'hub', '1')).toBe(
|
||||
'https://cdn.example.com/thumb.webp'
|
||||
)
|
||||
expect(getTemplateThumbnailUrl(template, 'hub', '2')).toBe(
|
||||
'https://cdn.example.com/compare.webp'
|
||||
)
|
||||
})
|
||||
|
||||
it('should format template titles correctly', () => {
|
||||
const { getTemplateTitle } = useTemplateWorkflows()
|
||||
|
||||
@@ -307,4 +365,27 @@ describe('useTemplateWorkflows', () => {
|
||||
// Restore console.error
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should load cloud templates by share id through the shared workflow service', async () => {
|
||||
distributionState.isCloud = true
|
||||
mockWorkflowTemplatesStore.isLoaded = true
|
||||
vi.mocked(fetch).mockClear()
|
||||
mockWorkflowTemplatesStore.getTemplateByName = vi.fn(() => ({
|
||||
name: 'template1',
|
||||
shareId: 'share-123',
|
||||
sourceModule: 'default',
|
||||
title: 'Template 1',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Template 1 description'
|
||||
}))
|
||||
|
||||
const { loadWorkflowTemplate } = useTemplateWorkflows()
|
||||
|
||||
const result = await loadWorkflowTemplate('template1', 'hub')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockGetSharedWorkflow).toHaveBeenCalledWith('share-123')
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
@@ -16,6 +17,7 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
export function useTemplateWorkflows() {
|
||||
const { t } = useI18n()
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const workflowShareService = useWorkflowShareService()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
// State
|
||||
@@ -64,6 +66,14 @@ export function useTemplateWorkflows() {
|
||||
sourceModule: string,
|
||||
index = '1'
|
||||
) => {
|
||||
if (template.thumbnailUrl) {
|
||||
if (index === '2' && template.thumbnailComparisonUrl) {
|
||||
return template.thumbnailComparisonUrl
|
||||
}
|
||||
|
||||
return template.thumbnailUrl
|
||||
}
|
||||
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
@@ -127,8 +137,10 @@ export function useTemplateWorkflows() {
|
||||
// Regular case for normal categories
|
||||
json = await fetchTemplateJson(id, sourceModule)
|
||||
|
||||
const workflowName =
|
||||
sourceModule === 'default'
|
||||
const template = workflowTemplatesStore.getTemplateByName(id)
|
||||
const workflowName = template?.shareId
|
||||
? (template.title ?? template.name)
|
||||
: sourceModule === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
@@ -157,6 +169,15 @@ export function useTemplateWorkflows() {
|
||||
* Fetches template JSON from the appropriate endpoint
|
||||
*/
|
||||
const fetchTemplateJson = async (id: string, sourceModule: string) => {
|
||||
const template = workflowTemplatesStore.getTemplateByName(id)
|
||||
|
||||
if (isCloud && template?.shareId) {
|
||||
const workflow = await workflowShareService.getSharedWorkflow(
|
||||
template.shareId
|
||||
)
|
||||
return workflow.workflowJson
|
||||
}
|
||||
|
||||
if (sourceModule === 'default') {
|
||||
// Default templates provided by frontend are served on this separate endpoint
|
||||
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useWorkflowTemplatesStore } from './workflowTemplatesStore'
|
||||
|
||||
const distributionState = vi.hoisted(() => ({
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getWorkflowTemplates: vi.fn(),
|
||||
getHubWorkflowTemplateIndex: vi.fn(),
|
||||
getCoreWorkflowTemplates: vi.fn(),
|
||||
fileURL: vi.fn((path: string) => path)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return distributionState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: apiMocks
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
i18n: {
|
||||
global: {
|
||||
locale: ref('en')
|
||||
}
|
||||
},
|
||||
st: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
describe('workflowTemplatesStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
distributionState.isCloud = true
|
||||
|
||||
apiMocks.getWorkflowTemplates.mockResolvedValue({})
|
||||
apiMocks.getHubWorkflowTemplateIndex.mockResolvedValue([
|
||||
{
|
||||
name: 'starter-template',
|
||||
title: 'Starter Template',
|
||||
status: 'approved',
|
||||
description: 'A cloud starter workflow',
|
||||
shareId: 'share-123',
|
||||
usage: 10,
|
||||
searchRank: 5,
|
||||
isEssential: true,
|
||||
section: 'Use Cases',
|
||||
sectionGroup: 'GENERATION TYPE',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.webp'
|
||||
},
|
||||
{
|
||||
name: 'image-gen',
|
||||
title: 'Image Generation',
|
||||
status: 'approved',
|
||||
description: 'Image generation workflow',
|
||||
shareId: 'share-456',
|
||||
section: 'Image',
|
||||
sectionGroup: 'GENERATION TYPE',
|
||||
thumbnailUrl: 'https://cdn.example.com/img.webp'
|
||||
},
|
||||
{
|
||||
name: 'video-gen',
|
||||
title: 'Video Generation',
|
||||
status: 'approved',
|
||||
description: 'Video generation workflow',
|
||||
shareId: 'share-789',
|
||||
section: 'Video',
|
||||
sectionGroup: 'GENERATION TYPE',
|
||||
thumbnailUrl: 'https://cdn.example.com/vid.webp'
|
||||
}
|
||||
])
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
headers: {
|
||||
get: vi.fn(() => 'application/json')
|
||||
},
|
||||
json: vi.fn().mockResolvedValue({})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('loads cloud templates from the hub index and resolves share ids', async () => {
|
||||
const store = useWorkflowTemplatesStore()
|
||||
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
const template = store.getTemplateByShareId('share-123')
|
||||
expect(template?.name).toBe('starter-template')
|
||||
expect(template?.shareId).toBe('share-123')
|
||||
expect(store.knownTemplateNames.has('starter-template')).toBe(true)
|
||||
})
|
||||
|
||||
it('builds nav groups from section metadata on cloud templates', async () => {
|
||||
const store = useWorkflowTemplatesStore()
|
||||
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
// Essential templates appear under Basics
|
||||
const basicsNav = store.navGroupedTemplates.find(
|
||||
(item) => 'id' in item && item.id === 'basics-use-cases'
|
||||
)
|
||||
expect(basicsNav).toBeDefined()
|
||||
expect(
|
||||
store.filterTemplatesByCategory('basics-use-cases').map((t) => t.name)
|
||||
).toEqual(['starter-template'])
|
||||
|
||||
// Non-essential templates grouped under GENERATION TYPE
|
||||
const genTypeGroup = store.navGroupedTemplates.find(
|
||||
(item) => 'title' in item && item.title === 'Generation Type'
|
||||
)
|
||||
expect(genTypeGroup).toBeDefined()
|
||||
expect('items' in genTypeGroup!).toBe(true)
|
||||
|
||||
const groupItems = (genTypeGroup as { items: Array<{ id: string }> }).items
|
||||
expect(groupItems.map((i) => i.id)).toEqual(
|
||||
expect.arrayContaining(['generation-type-image', 'generation-type-video'])
|
||||
)
|
||||
})
|
||||
|
||||
it('filters templates by section-based category id', async () => {
|
||||
const store = useWorkflowTemplatesStore()
|
||||
|
||||
await store.loadWorkflowTemplates()
|
||||
|
||||
// Access navGroupedTemplates to populate categoryFilters
|
||||
expect(store.navGroupedTemplates.length).toBeGreaterThan(0)
|
||||
|
||||
const imageTemplates = store.filterTemplatesByCategory(
|
||||
'generation-type-image'
|
||||
)
|
||||
expect(imageTemplates.map((t) => t.name)).toEqual(['image-gen'])
|
||||
|
||||
const videoTemplates = store.filterTemplatesByCategory(
|
||||
'generation-type-video'
|
||||
)
|
||||
expect(videoTemplates.map((t) => t.name)).toEqual(['video-gen'])
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import { mapHubWorkflowIndexToCategories } from '../adapters/hubWorkflowIndexMapper'
|
||||
import { zLogoIndex } from '../schemas/templateSchema'
|
||||
import type { LogoIndex } from '../schemas/templateSchema'
|
||||
import type {
|
||||
@@ -41,6 +42,14 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
return enhancedTemplates.value.find((template) => template.name === name)
|
||||
}
|
||||
|
||||
const getTemplateByShareId = (
|
||||
shareId: string
|
||||
): EnhancedTemplate | undefined => {
|
||||
return enhancedTemplates.value.find(
|
||||
(template) => template.shareId === shareId
|
||||
)
|
||||
}
|
||||
|
||||
// Store filter mappings for dynamic categories
|
||||
type FilterData = {
|
||||
category?: string
|
||||
@@ -204,7 +213,7 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
category: category.title,
|
||||
categoryType: category.type,
|
||||
categoryGroup: category.category,
|
||||
isEssential: category.isEssential,
|
||||
isEssential: template.isEssential ?? category.isEssential,
|
||||
isPartnerNode: template.openSource === false,
|
||||
searchableText: [
|
||||
template.title || template.name,
|
||||
@@ -261,12 +270,16 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
}
|
||||
|
||||
if (categoryId.startsWith('basics-')) {
|
||||
const basicsCategory = categoryId.replace('basics-', '')
|
||||
|
||||
// Filter for templates from categories marked as essential
|
||||
return enhancedTemplates.value.filter(
|
||||
(t) =>
|
||||
t.isEssential &&
|
||||
t.category?.toLowerCase().replace(/\s+/g, '-') ===
|
||||
categoryId.replace('basics-', '')
|
||||
(t.category?.toLowerCase().replace(/\s+/g, '-') ===
|
||||
basicsCategory ||
|
||||
(basicsCategory === 'getting-started' &&
|
||||
(!t.category || t.sourceModule === 'default')))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -355,6 +368,17 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
getCategoryIcon(essentialCat.type || 'getting-started')
|
||||
})
|
||||
})
|
||||
} else if (
|
||||
enhancedTemplates.value.some((template) => template.isEssential)
|
||||
) {
|
||||
items.push({
|
||||
id: generateCategoryId('basics', 'Getting Started'),
|
||||
label: st(
|
||||
'templateWorkflows.category.Getting Started',
|
||||
'Getting Started'
|
||||
),
|
||||
icon: getCategoryIcon('getting-started')
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Group categories from JSON dynamically
|
||||
@@ -473,10 +497,28 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
})
|
||||
|
||||
async function fetchCoreTemplates() {
|
||||
if (isCloud) {
|
||||
const [hubIndexResult, logoIndexResult] = await Promise.all([
|
||||
api.getHubWorkflowTemplateIndex(),
|
||||
fetchLogoIndex()
|
||||
])
|
||||
|
||||
coreTemplates.value = mapHubWorkflowIndexToCategories(hubIndexResult)
|
||||
englishTemplates.value = []
|
||||
logoIndex.value = logoIndexResult
|
||||
|
||||
const coreNames = coreTemplates.value.flatMap((category) =>
|
||||
category.templates.map((template) => template.name)
|
||||
)
|
||||
const customNames = Object.values(customTemplates.value).flat()
|
||||
knownTemplateNames.value = new Set([...coreNames, ...customNames])
|
||||
return
|
||||
}
|
||||
|
||||
const locale = i18n.global.locale.value
|
||||
const [coreResult, englishResult, logoIndexResult] = await Promise.all([
|
||||
api.getCoreWorkflowTemplates(locale),
|
||||
isCloud && locale !== 'en'
|
||||
locale !== 'en'
|
||||
? api.getCoreWorkflowTemplates('en')
|
||||
: Promise.resolve([]),
|
||||
fetchLogoIndex()
|
||||
@@ -583,6 +625,7 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
loadWorkflowTemplates,
|
||||
knownTemplateNames,
|
||||
getTemplateByName,
|
||||
getTemplateByShareId,
|
||||
getEnglishMetadata,
|
||||
getLogoUrl
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { zHubWorkflowTemplateEntry } from '@comfyorg/ingest-types/zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
// The live cloud index response currently includes fields that are not yet
|
||||
// present in the generated ingest OpenAPI types.
|
||||
export const zHubWorkflowIndexEntry = zHubWorkflowTemplateEntry.extend({
|
||||
usage: z.number().optional(),
|
||||
searchRank: z.number().optional(),
|
||||
isEssential: z.boolean().optional(),
|
||||
useCase: z.string().optional(),
|
||||
license: z.string().optional(),
|
||||
// TODO(hub-api): Pending BE spec confirmation — field names may change.
|
||||
// These enable category nav grouping (e.g. section="Image", sectionGroup="GENERATION TYPE").
|
||||
section: z.string().optional(),
|
||||
sectionGroup: z.string().optional()
|
||||
})
|
||||
|
||||
export const zHubWorkflowIndexResponse = z.array(zHubWorkflowIndexEntry)
|
||||
|
||||
export type HubWorkflowIndexEntry = z.infer<typeof zHubWorkflowIndexEntry>
|
||||
@@ -26,6 +26,7 @@ export interface TemplateInfo {
|
||||
localizedDescription?: string
|
||||
isEssential?: boolean
|
||||
sourceModule?: string
|
||||
shareId?: string
|
||||
tags?: string[]
|
||||
models?: string[]
|
||||
date?: string
|
||||
@@ -40,6 +41,8 @@ export interface TemplateInfo {
|
||||
* Whether this template uses open source models. When false, indicates partner/API node templates.
|
||||
*/
|
||||
openSource?: boolean
|
||||
thumbnailUrl?: string
|
||||
thumbnailComparisonUrl?: string
|
||||
/**
|
||||
* Array of custom node package IDs required for this template (from Custom Node Registry).
|
||||
* Templates with this field will be hidden on local installations temporarily.
|
||||
|
||||
81
src/platform/workflow/templates/utils/hubAssetUrl.test.ts
Normal file
81
src/platform/workflow/templates/utils/hubAssetUrl.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getVideoFrameUrl, isVideoSrc, rewriteHubAssetUrl } from './hubAssetUrl'
|
||||
|
||||
describe('isVideoSrc', () => {
|
||||
it.each([
|
||||
['https://cdn.example.com/clip.mp4', true],
|
||||
['https://cdn.example.com/clip.webm', true],
|
||||
['https://cdn.example.com/clip.MOV', true],
|
||||
['/__hub_assets/uploads/clip.mp4', true],
|
||||
['https://cdn.example.com/thumb.webp', false],
|
||||
['https://cdn.example.com/thumb.png', false],
|
||||
['', false]
|
||||
])('isVideoSrc(%s) → %s', (url, expected) => {
|
||||
expect(isVideoSrc(url)).toBe(expected)
|
||||
})
|
||||
|
||||
it('handles undefined', () => {
|
||||
expect(isVideoSrc(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getVideoFrameUrl', () => {
|
||||
it('inserts Cloudflare frame segment after the hub-assets origin', () => {
|
||||
expect(
|
||||
getVideoFrameUrl('https://comfy-hub-assets.comfy.org/uploads/abc.mp4')
|
||||
).toBe(
|
||||
'https://comfy-hub-assets.comfy.org/cdn-cgi/media/mode=frame,time=1s/uploads/abc.mp4'
|
||||
)
|
||||
})
|
||||
|
||||
it('inserts Cloudflare frame segment after the dev proxy prefix', () => {
|
||||
expect(getVideoFrameUrl('/__hub_assets/uploads/abc.mp4')).toBe(
|
||||
'/__hub_assets/cdn-cgi/media/mode=frame,time=1s/uploads/abc.mp4'
|
||||
)
|
||||
})
|
||||
|
||||
it('supports custom time offset', () => {
|
||||
expect(
|
||||
getVideoFrameUrl(
|
||||
'https://comfy-hub-assets.comfy.org/uploads/abc.mp4',
|
||||
'3s'
|
||||
)
|
||||
).toBe(
|
||||
'https://comfy-hub-assets.comfy.org/cdn-cgi/media/mode=frame,time=3s/uploads/abc.mp4'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the URL unchanged for unknown hosts', () => {
|
||||
const unrelated = 'https://cdn.example.com/clip.mp4'
|
||||
expect(getVideoFrameUrl(unrelated)).toBe(unrelated)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rewriteHubAssetUrl', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('DEV', true)
|
||||
})
|
||||
|
||||
it('returns undefined when url is missing', () => {
|
||||
expect(rewriteHubAssetUrl(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rewrites hub-assets URLs in dev mode', () => {
|
||||
expect(
|
||||
rewriteHubAssetUrl('https://comfy-hub-assets.comfy.org/uploads/x.mp4')
|
||||
).toBe('/__hub_assets/uploads/x.mp4')
|
||||
})
|
||||
|
||||
it('passes through unrelated URLs unchanged', () => {
|
||||
expect(rewriteHubAssetUrl('https://cdn.example.com/x.mp4')).toBe(
|
||||
'https://cdn.example.com/x.mp4'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes through unchanged when not in dev mode', () => {
|
||||
vi.stubEnv('DEV', false)
|
||||
const absolute = 'https://comfy-hub-assets.comfy.org/uploads/x.mp4'
|
||||
expect(rewriteHubAssetUrl(absolute)).toBe(absolute)
|
||||
})
|
||||
})
|
||||
46
src/platform/workflow/templates/utils/hubAssetUrl.ts
Normal file
46
src/platform/workflow/templates/utils/hubAssetUrl.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
const HUB_ASSETS_ORIGIN = 'https://comfy-hub-assets.comfy.org'
|
||||
const HUB_ASSETS_DEV_PREFIX = '/__hub_assets'
|
||||
const CF_MEDIA_PATH_PREFIX = '/cdn-cgi/media'
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov'] as const
|
||||
|
||||
type VideoExtension = (typeof VIDEO_EXTENSIONS)[number]
|
||||
|
||||
function getUrlExtension(url: string): string | undefined {
|
||||
try {
|
||||
const pathname = url.startsWith('http') ? new URL(url).pathname : url
|
||||
return pathname.split('.').pop()?.toLowerCase() || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function rewriteHubAssetUrl(url?: string): string | undefined {
|
||||
if (!url || !import.meta.env.DEV) return url
|
||||
return url.startsWith(HUB_ASSETS_ORIGIN)
|
||||
? `${HUB_ASSETS_DEV_PREFIX}${url.slice(HUB_ASSETS_ORIGIN.length)}`
|
||||
: url
|
||||
}
|
||||
|
||||
export function isVideoSrc(url?: string): boolean {
|
||||
const extension = url ? getUrlExtension(url) : undefined
|
||||
return (
|
||||
!!extension &&
|
||||
(VIDEO_EXTENSIONS as readonly string[]).includes(
|
||||
extension as VideoExtension
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function getVideoFrameUrl(url: string, time = '1s'): string {
|
||||
const segment = `${CF_MEDIA_PATH_PREFIX}/mode=frame,time=${time}`
|
||||
|
||||
if (url.startsWith(HUB_ASSETS_ORIGIN)) {
|
||||
return `${HUB_ASSETS_ORIGIN}${segment}${url.slice(HUB_ASSETS_ORIGIN.length)}`
|
||||
}
|
||||
|
||||
if (url.startsWith(HUB_ASSETS_DEV_PREFIX)) {
|
||||
return `${HUB_ASSETS_DEV_PREFIX}${segment}${url.slice(HUB_ASSETS_DEV_PREFIX.length)}`
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
import { zHubWorkflowIndexResponse } from '@/platform/workflow/templates/schemas/hubWorkflowIndexSchema'
|
||||
import type { HubWorkflowIndexEntry } from '@/platform/workflow/templates/schemas/hubWorkflowIndexSchema'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON,
|
||||
@@ -827,6 +829,21 @@ export class ComfyApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
async getHubWorkflowTemplateIndex(): Promise<HubWorkflowIndexEntry[]> {
|
||||
const res = await this.fetchApi('/hub/workflows/index')
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load hub workflow index: ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const parsed = zHubWorkflowIndexResponse.safeParse(data)
|
||||
if (!parsed.success) {
|
||||
throw new Error('Invalid hub workflow index response')
|
||||
}
|
||||
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a list of embedding names
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user