Compare commits

...

4 Commits

Author SHA1 Message Date
dante01yoon
589f7b434e feat(templates): autoplay video thumbnails with Cloudflare poster fallback
Hub workflow entries can deliver .mp4/.webm/.mov thumbnails, which were
failing to render in <img> tags and falling through to the default asset.

Introduce LazyMedia: renders <video autoplay loop muted playsinline> with
a Cloudflare frame (cdn-cgi/media/mode=frame,time=1s) as the poster and
the fallback image when the video fails to load. The three template
thumbnail variants now delegate to LazyMedia so every surface benefits.
Shared hubAssetUrl util handles origin rewriting for the dev proxy and
the Cloudflare transform URL.
2026-04-17 13:03:11 +09:00
dante01yoon
97b160113c fix(templates): use workflow title for hub template tab name
Hub-sourced entries use shareId as name, so the template tab fell back to
the raw share id. Prefer the workflow title when a shareId is present.
2026-04-17 13:03:11 +09:00
dante01yoon
dcf4bb3534 feat(templates): group hub index entries by section metadata
Add section/sectionGroup fields to hub workflow index schema to enable
category nav grouping on cloud. The mapper now groups entries by section
(e.g. "Image", "Video") under sectionGroup headers (e.g. "GENERATION TYPE")
instead of flattening everything into a single "All" category.

Falls back to flat "All" when BE does not yet provide section metadata.

TODO(hub-api): field names pending BE spec confirmation.
2026-04-15 18:44:56 +09:00
dante01yoon
0e7c4c1426 feat(templates): use hub workflow index on cloud 2026-04-14 12:34:18 +09:00
21 changed files with 1104 additions and 23 deletions

View File

@@ -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']

View File

@@ -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

View File

@@ -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']

View File

@@ -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>

View File

@@ -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']

View File

@@ -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

View 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()
})
})

View 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>

View File

@@ -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)
})
})

View File

@@ -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
}
}
)
}

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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()
})
})

View File

@@ -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())

View File

@@ -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'])
})
})

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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.

View 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)
})
})

View 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
}

View File

@@ -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
*/