mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-01 22:09:55 +00:00
feat: add provider logo overlays to workflow template thumbnails (#8365)
## Summary Add support for overlaying provider logos on workflow template thumbnails at runtime. ## Changes - **What**: - Add `LogoInfo` interface and `logos` field to `TemplateInfo` type - Create `LogoOverlay.vue` component for rendering positioned logos - Fetch logo index from `templates/index_logo.json` in store - Add `getLogoUrl` helper to `useTemplateWorkflows` composable - Integrate `LogoOverlay` into `WorkflowTemplateSelectorDialog` ## Review Focus - Logo positioning uses Tailwind classes (e.g. `absolute bottom-2 right-2`) - Supports multiple logos per template with configurable size/opacity - Gracefully handles missing logos (returns empty string, renders nothing) - Templates must explicitly declare logos - no magic inference from models ## Dependencies Requires separate PR in workflow_templates repo to: 1. Update `index.schema.json` with logos definition 2. Add `logos` field to templates in `index.json` ## Screenshots (if applicable) <img width="869" height="719" alt="image" src="https://github.com/user-attachments/assets/65ed1ee4-fbb4-42c9-95d4-7e37813b3655" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8365-feat-add-provider-logo-overlays-to-workflow-template-thumbnails-2f66d73d365081309236c6b991cb6f7b) by [Unito](https://www.unito.io) --------- Co-authored-by: Subagent 5 <subagent@example.com> Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -256,6 +256,11 @@
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<LogoOverlay
|
||||
v-if="template.logos?.length"
|
||||
:logos="template.logos"
|
||||
:get-logo-url="workflowTemplatesStore.getLogoUrl"
|
||||
/>
|
||||
<ProgressSpinner
|
||||
v-if="loadingTemplate === template.name"
|
||||
class="absolute inset-0 z-10 m-auto h-12 w-12"
|
||||
@@ -397,6 +402,7 @@ import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
|
||||
import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
|
||||
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
|
||||
|
||||
157
src/components/templates/thumbnails/LogoOverlay.test.ts
Normal file
157
src/components/templates/thumbnails/LogoOverlay.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import LogoOverlay from '@/components/templates/thumbnails/LogoOverlay.vue'
|
||||
import type { LogoInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) =>
|
||||
key === 'templates.logoProviderSeparator' ? ' & ' : key,
|
||||
locale: ref('en')
|
||||
})
|
||||
}))
|
||||
|
||||
type LogoOverlayProps = ComponentProps<typeof LogoOverlay>
|
||||
|
||||
describe('LogoOverlay', () => {
|
||||
function mockGetLogoUrl(provider: string) {
|
||||
return `/logos/${provider}.png`
|
||||
}
|
||||
|
||||
function mountOverlay(
|
||||
logos: LogoInfo[],
|
||||
props: Partial<LogoOverlayProps> = {}
|
||||
) {
|
||||
return mount(LogoOverlay, {
|
||||
props: {
|
||||
logos,
|
||||
getLogoUrl: mockGetLogoUrl,
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders nothing when logos array is empty', () => {
|
||||
const wrapper = mountOverlay([])
|
||||
expect(wrapper.findAll('img')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('renders a single logo with correct src and alt', () => {
|
||||
const wrapper = mountOverlay([{ provider: 'Google' }])
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('src')).toBe('/logos/Google.png')
|
||||
expect(img.attributes('alt')).toBe('Google')
|
||||
})
|
||||
|
||||
it('renders multiple separate logo entries', () => {
|
||||
const wrapper = mountOverlay([
|
||||
{ provider: 'Google' },
|
||||
{ provider: 'OpenAI' },
|
||||
{ provider: 'Stability' }
|
||||
])
|
||||
expect(wrapper.findAll('img')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('displays provider name as label for single provider', () => {
|
||||
const wrapper = mountOverlay([{ provider: 'Google' }])
|
||||
const span = wrapper.find('span')
|
||||
expect(span.text()).toBe('Google')
|
||||
})
|
||||
|
||||
it('images are not draggable', () => {
|
||||
const wrapper = mountOverlay([{ provider: 'Google' }])
|
||||
const img = wrapper.find('img')
|
||||
expect(img.attributes('draggable')).toBe('false')
|
||||
})
|
||||
|
||||
it('filters out logos with empty URLs', () => {
|
||||
function getLogoUrl(provider: string) {
|
||||
return provider === 'Google' ? '/logos/Google.png' : ''
|
||||
}
|
||||
const wrapper = mount(LogoOverlay, {
|
||||
props: {
|
||||
logos: [{ provider: 'Google' }, { provider: 'Unknown' }],
|
||||
getLogoUrl
|
||||
}
|
||||
})
|
||||
expect(wrapper.findAll('img')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders one logo per unique provider', () => {
|
||||
const wrapper = mountOverlay([
|
||||
{ provider: 'Google' },
|
||||
{ provider: 'OpenAI' }
|
||||
])
|
||||
expect(wrapper.findAll('img')).toHaveLength(2)
|
||||
})
|
||||
|
||||
describe('stacked logos', () => {
|
||||
it('renders multiple providers as stacked overlapping logos', () => {
|
||||
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images).toHaveLength(2)
|
||||
expect(images[0].attributes('alt')).toBe('WaveSpeed')
|
||||
expect(images[1].attributes('alt')).toBe('Hunyuan')
|
||||
})
|
||||
|
||||
it('joins provider names with locale-aware conjunction for default label', () => {
|
||||
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
|
||||
const span = wrapper.find('span')
|
||||
expect(span.text()).toBe('WaveSpeed and Hunyuan')
|
||||
})
|
||||
|
||||
it('uses custom label when provided', () => {
|
||||
const wrapper = mountOverlay([
|
||||
{ provider: ['WaveSpeed', 'Hunyuan'], label: 'Custom Label' }
|
||||
])
|
||||
const span = wrapper.find('span')
|
||||
expect(span.text()).toBe('Custom Label')
|
||||
})
|
||||
|
||||
it('applies negative gap for overlap effect', () => {
|
||||
const wrapper = mountOverlay([
|
||||
{ provider: ['WaveSpeed', 'Hunyuan'], gap: -8 }
|
||||
])
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[1].attributes('style')).toContain('margin-left: -8px')
|
||||
})
|
||||
|
||||
it('applies default gap when not specified', () => {
|
||||
const wrapper = mountOverlay([{ provider: ['WaveSpeed', 'Hunyuan'] }])
|
||||
const images = wrapper.findAll('img')
|
||||
expect(images[1].attributes('style')).toContain('margin-left: -6px')
|
||||
})
|
||||
|
||||
it('filters out invalid providers from stacked logos', () => {
|
||||
function getLogoUrl(provider: string) {
|
||||
return provider === 'WaveSpeed' ? '/logos/WaveSpeed.png' : ''
|
||||
}
|
||||
const wrapper = mount(LogoOverlay, {
|
||||
props: {
|
||||
logos: [{ provider: ['WaveSpeed', 'Unknown'] }],
|
||||
getLogoUrl
|
||||
}
|
||||
})
|
||||
expect(wrapper.findAll('img')).toHaveLength(1)
|
||||
expect(wrapper.find('span').text()).toBe('WaveSpeed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('keeps showing remaining providers when one image fails in stacked logos', async () => {
|
||||
const wrapper = mountOverlay([{ provider: ['Google', 'OpenAI'] }])
|
||||
const images = wrapper.findAll('[data-testid="logo-img"]')
|
||||
expect(images).toHaveLength(2)
|
||||
|
||||
await images[0].trigger('error')
|
||||
await nextTick()
|
||||
|
||||
const remainingImages = wrapper.findAll('[data-testid="logo-img"]')
|
||||
expect(remainingImages).toHaveLength(2)
|
||||
expect(remainingImages[1].attributes('alt')).toBe('OpenAI')
|
||||
})
|
||||
})
|
||||
})
|
||||
117
src/components/templates/thumbnails/LogoOverlay.vue
Normal file
117
src/components/templates/thumbnails/LogoOverlay.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div
|
||||
v-for="logo in validLogos"
|
||||
:key="logo.key"
|
||||
:class="
|
||||
cn('pointer-events-none absolute z-10', logo.position ?? defaultPosition)
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-show="!hasAllFailed(logo.providers)"
|
||||
data-testid="logo-pill"
|
||||
class="flex items-center gap-1.5 rounded-full bg-black/20 py-1 pr-2"
|
||||
:style="{ opacity: logo.opacity ?? 0.85 }"
|
||||
>
|
||||
<div class="ml-0.5 flex items-center">
|
||||
<img
|
||||
v-for="(provider, providerIndex) in logo.providers"
|
||||
:key="provider"
|
||||
data-testid="logo-img"
|
||||
:src="logo.urls[providerIndex]"
|
||||
:alt="provider"
|
||||
class="h-6 w-6 rounded-full border-2 border-white object-cover"
|
||||
:class="{ relative: providerIndex > 0 }"
|
||||
:style="
|
||||
providerIndex > 0 ? { marginLeft: `${logo.gap ?? -6}px` } : {}
|
||||
"
|
||||
draggable="false"
|
||||
@error="onImageError(provider)"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-medium text-white">
|
||||
{{ logo.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import type { LogoInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
function formatProviderList(providers: string[]): string {
|
||||
const localeValue = String(locale.value)
|
||||
try {
|
||||
return new Intl.ListFormat(localeValue, {
|
||||
style: 'long',
|
||||
type: 'conjunction'
|
||||
}).format(providers)
|
||||
} catch {
|
||||
return providers.join(t('templates.logoProviderSeparator'))
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
logos,
|
||||
getLogoUrl,
|
||||
defaultPosition = 'top-2 left-2'
|
||||
} = defineProps<{
|
||||
logos: LogoInfo[]
|
||||
getLogoUrl: (provider: string) => string
|
||||
defaultPosition?: string
|
||||
}>()
|
||||
|
||||
const failedLogos = ref(new Set<string>())
|
||||
|
||||
function onImageError(provider: string) {
|
||||
failedLogos.value = new Set([...failedLogos.value, provider])
|
||||
}
|
||||
|
||||
function hasAllFailed(providers: string[]): boolean {
|
||||
return providers.every((p) => failedLogos.value.has(p))
|
||||
}
|
||||
|
||||
interface ValidatedLogo {
|
||||
key: string
|
||||
providers: string[]
|
||||
urls: string[]
|
||||
label: string
|
||||
position: string | undefined
|
||||
opacity: number | undefined
|
||||
gap: number | undefined
|
||||
}
|
||||
|
||||
const validLogos = computed<ValidatedLogo[]>(() => {
|
||||
const result: ValidatedLogo[] = []
|
||||
|
||||
logos.forEach((logo, index) => {
|
||||
const providers = Array.isArray(logo.provider)
|
||||
? logo.provider
|
||||
: [logo.provider]
|
||||
const urls = providers.map((p) => getLogoUrl(p))
|
||||
const validProviders = providers.filter((_, i) => urls[i])
|
||||
const validUrls = urls.filter((url) => url)
|
||||
|
||||
if (validProviders.length === 0) return
|
||||
|
||||
const providerKey = validProviders.join('-')
|
||||
const layoutKey = `${logo.position ?? ''}-${logo.opacity ?? ''}-${logo.gap ?? ''}`
|
||||
result.push({
|
||||
key: providerKey ? `${providerKey}-${layoutKey}` : `logo-${index}`,
|
||||
providers: validProviders,
|
||||
urls: validUrls,
|
||||
label: logo.label ?? formatProviderList(validProviders),
|
||||
position: logo.position,
|
||||
opacity: logo.opacity,
|
||||
gap: logo.gap
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
@@ -966,6 +966,9 @@
|
||||
"searchPlaceholder": "Search..."
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
"logoProviderSeparator": " & "
|
||||
},
|
||||
"graphCanvasMenu": {
|
||||
"zoomIn": "Zoom In",
|
||||
"zoomOut": "Zoom Out",
|
||||
|
||||
@@ -8,6 +8,8 @@ import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { generateCategoryId, getCategoryIcon } from '@/utils/categoryUtil'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
|
||||
import { zLogoIndex } from '../schemas/templateSchema'
|
||||
import type { LogoIndex } from '../schemas/templateSchema'
|
||||
import type {
|
||||
TemplateGroup,
|
||||
TemplateInfo,
|
||||
@@ -31,6 +33,7 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({})
|
||||
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
|
||||
const englishTemplates = shallowRef<WorkflowTemplates[]>([])
|
||||
const logoIndex = shallowRef<LogoIndex>({})
|
||||
const isLoaded = ref(false)
|
||||
const knownTemplateNames = ref(new Set<string>())
|
||||
|
||||
@@ -475,15 +478,18 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
customTemplates.value = await api.getWorkflowTemplates()
|
||||
const locale = i18n.global.locale.value
|
||||
|
||||
const [coreResult, englishResult] = await Promise.all([
|
||||
api.getCoreWorkflowTemplates(locale),
|
||||
isCloud && locale !== 'en'
|
||||
? api.getCoreWorkflowTemplates('en')
|
||||
: Promise.resolve([])
|
||||
])
|
||||
const [coreResult, englishResult, logoIndexResult] =
|
||||
await Promise.all([
|
||||
api.getCoreWorkflowTemplates(locale),
|
||||
isCloud && locale !== 'en'
|
||||
? api.getCoreWorkflowTemplates('en')
|
||||
: Promise.resolve([]),
|
||||
fetchLogoIndex()
|
||||
])
|
||||
|
||||
coreTemplates.value = coreResult
|
||||
englishTemplates.value = englishResult
|
||||
logoIndex.value = logoIndexResult
|
||||
|
||||
const coreNames = coreTemplates.value.flatMap((category) =>
|
||||
category.templates.map((template) => template.name)
|
||||
@@ -498,6 +504,36 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLogoIndex(): Promise<LogoIndex> {
|
||||
try {
|
||||
const response = await api.fetchApi('/templates/index_logo.json')
|
||||
const contentType = response.headers.get('content-type')
|
||||
if (!contentType?.includes('application/json')) return {}
|
||||
const data = await response.json()
|
||||
const result = zLogoIndex.safeParse(data)
|
||||
return result.success ? result.data : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function getLogoUrl(provider: string): string {
|
||||
const logoPath = logoIndex.value[provider]
|
||||
if (!logoPath) return ''
|
||||
|
||||
// Validate path to prevent directory traversal and ensure safe file extensions
|
||||
const safePathPattern = /^[a-zA-Z0-9_\-./]+\.(png|jpg|jpeg|svg|webp)$/i
|
||||
if (
|
||||
!safePathPattern.test(logoPath) ||
|
||||
logoPath.includes('..') ||
|
||||
logoPath.startsWith('/')
|
||||
) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return api.fileURL(`/templates/${logoPath}`)
|
||||
}
|
||||
|
||||
function getEnglishMetadata(templateName: string): {
|
||||
tags?: string[]
|
||||
category?: string
|
||||
@@ -534,7 +570,8 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
loadWorkflowTemplates,
|
||||
knownTemplateNames,
|
||||
getTemplateByName,
|
||||
getEnglishMetadata
|
||||
getEnglishMetadata,
|
||||
getLogoUrl
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const zLogoIndex = z.record(z.string(), z.string())
|
||||
|
||||
export type LogoIndex = z.infer<typeof zLogoIndex>
|
||||
@@ -1,3 +1,16 @@
|
||||
export interface LogoInfo {
|
||||
/** Provider name(s) matching index_logo.json. String for single, array for stacked logos. */
|
||||
provider: string | string[]
|
||||
/** Custom label text. If omitted, defaults to provider names joined with " & " */
|
||||
label?: string
|
||||
/** Gap between stacked logos in pixels. Negative for overlap effect. Default: -6 */
|
||||
gap?: number
|
||||
/** Tailwind positioning classes */
|
||||
position?: string
|
||||
/** Opacity 0-1, default 0.85 */
|
||||
opacity?: number
|
||||
}
|
||||
|
||||
export interface TemplateInfo {
|
||||
name: string
|
||||
/**
|
||||
@@ -47,6 +60,10 @@ export interface TemplateInfo {
|
||||
* If not specified, the template will be included on all distributions.
|
||||
*/
|
||||
includeOnDistributions?: TemplateIncludeOnDistributionEnum[]
|
||||
/**
|
||||
* Logo overlays to display on the template thumbnail.
|
||||
*/
|
||||
logos?: LogoInfo[]
|
||||
}
|
||||
|
||||
export enum TemplateIncludeOnDistributionEnum {
|
||||
|
||||
Reference in New Issue
Block a user