mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 21:54:50 +00:00
Compare commits
6 Commits
pysssss/pr
...
glary/be-6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
797e4386f6 | ||
|
|
fc2a347ef2 | ||
|
|
eb18c25ff7 | ||
|
|
223bfb8f39 | ||
|
|
6101c7a1f7 | ||
|
|
e6d848a0e1 |
@@ -102,6 +102,23 @@ export class PublishApiHelper {
|
||||
})
|
||||
}
|
||||
|
||||
async mockHubWorkflowDetail(
|
||||
shareId: string,
|
||||
detail: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
await this.addRoute(`**/hub/workflows/${shareId}`, async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.continue()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(detail)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async mockShareableAssets(assets: AssetInfo[] = []): Promise<void> {
|
||||
const response: ShareableAssetsResponse = { assets }
|
||||
await this.addRoute('**/assets/from-workflow', async (route) => {
|
||||
|
||||
118
browser_tests/tests/dialogs/publishDialogPrefill.spec.ts
Normal file
118
browser_tests/tests/dialogs/publishDialogPrefill.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { PublishDialog } from '@e2e/fixtures/components/PublishDialog'
|
||||
|
||||
import { publishFixture as test } from '@e2e/fixtures/helpers/PublishApiHelper'
|
||||
|
||||
const PUBLISH_FEATURE_FLAGS = {
|
||||
comfyhub_upload_enabled: true,
|
||||
comfyhub_profile_gate_enabled: true
|
||||
} as const
|
||||
|
||||
const PUBLISHED_SHARE_ID = 'prefill-share-id-1'
|
||||
const PUBLISHED_WORKFLOW_NAME = 'Cinematic Upscale'
|
||||
const PUBLISHED_DESCRIPTION = 'A polished cinematic upscale workflow.'
|
||||
const PUBLISHED_THUMBNAIL_URL = 'https://cdn.example.com/thumb.png'
|
||||
const PUBLISHED_SAMPLE_URL = 'https://cdn.example.com/sample-1.png'
|
||||
const PUBLISHED_TUTORIAL_URL = 'https://www.youtube.com/watch?v=demo'
|
||||
|
||||
async function saveAndOpenPublishDialog(
|
||||
comfyPage: ComfyPage,
|
||||
dialog: PublishDialog,
|
||||
workflowName: string
|
||||
): Promise<void> {
|
||||
await comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
const overwriteDialog = comfyPage.page.locator(
|
||||
'.p-dialog:has-text("Overwrite")'
|
||||
)
|
||||
try {
|
||||
await overwriteDialog.waitFor({ state: 'visible', timeout: 500 })
|
||||
await comfyPage.confirmDialog.click('overwrite')
|
||||
} catch {
|
||||
/* no-op when the workflow name is unique */
|
||||
}
|
||||
await dialog.open()
|
||||
}
|
||||
|
||||
test.describe('Publish dialog - prefill on republish', () => {
|
||||
test.beforeEach(async ({ comfyPage, publishApi }) => {
|
||||
await comfyPage.featureFlags.setFlags(PUBLISH_FEATURE_FLAGS)
|
||||
await publishApi.setupDefaultMocks({ hasProfile: true })
|
||||
await publishApi.mockPublishStatus({
|
||||
workflow_id: 'wf-prefill-1',
|
||||
share_id: PUBLISHED_SHARE_ID,
|
||||
publish_time: '2026-04-01T12:00:00Z',
|
||||
listed: true,
|
||||
assets: []
|
||||
})
|
||||
await publishApi.mockHubWorkflowDetail(PUBLISHED_SHARE_ID, {
|
||||
name: PUBLISHED_WORKFLOW_NAME,
|
||||
description: PUBLISHED_DESCRIPTION,
|
||||
tags: [
|
||||
{ name: 'anime', display_name: 'anime' },
|
||||
{ name: 'upscale', display_name: 'upscale' }
|
||||
],
|
||||
models: [{ name: 'sdxl', display_name: 'SDXL' }],
|
||||
custom_nodes: [],
|
||||
thumbnail_type: 'image',
|
||||
thumbnail_url: PUBLISHED_THUMBNAIL_URL,
|
||||
sample_image_urls: [PUBLISHED_SAMPLE_URL],
|
||||
tutorial_url: PUBLISHED_TUTORIAL_URL,
|
||||
metadata: {}
|
||||
})
|
||||
})
|
||||
|
||||
test('prefills name and description from existing hub workflow', async ({
|
||||
comfyPage,
|
||||
publishDialog
|
||||
}) => {
|
||||
await saveAndOpenPublishDialog(
|
||||
comfyPage,
|
||||
publishDialog,
|
||||
'test-prefill-describe'
|
||||
)
|
||||
|
||||
await expect(publishDialog.nameInput).toHaveValue(PUBLISHED_WORKFLOW_NAME)
|
||||
await expect(publishDialog.descriptionTextarea).toHaveValue(
|
||||
PUBLISHED_DESCRIPTION
|
||||
)
|
||||
})
|
||||
|
||||
test('prefills the thumbnail preview from existing hub workflow', async ({
|
||||
comfyPage,
|
||||
publishDialog
|
||||
}) => {
|
||||
await saveAndOpenPublishDialog(
|
||||
comfyPage,
|
||||
publishDialog,
|
||||
'test-prefill-thumb'
|
||||
)
|
||||
await publishDialog.goNext()
|
||||
|
||||
const thumbnailPreview =
|
||||
publishDialog.root.getByAltText('Thumbnail preview')
|
||||
await expect(thumbnailPreview).toBeVisible()
|
||||
await expect(thumbnailPreview).toHaveAttribute(
|
||||
'src',
|
||||
PUBLISHED_THUMBNAIL_URL
|
||||
)
|
||||
})
|
||||
|
||||
test('prefills example images from existing hub workflow', async ({
|
||||
comfyPage,
|
||||
publishDialog
|
||||
}) => {
|
||||
await saveAndOpenPublishDialog(
|
||||
comfyPage,
|
||||
publishDialog,
|
||||
'test-prefill-examples'
|
||||
)
|
||||
await publishDialog.goNext()
|
||||
|
||||
const sampleImage = publishDialog.root.locator(
|
||||
`img[src="${PUBLISHED_SAMPLE_URL}"]`
|
||||
)
|
||||
await expect(sampleImage).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -52,8 +52,11 @@ function createDefaultFormData(): ComfyHubPublishFormData {
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonBeforeUrl: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
@@ -137,7 +140,24 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
template: '<div data-testid="examples-step" />'
|
||||
},
|
||||
ComfyHubThumbnailStep: {
|
||||
template: '<div data-testid="thumbnail-step" />'
|
||||
template:
|
||||
'<div data-testid="thumbnail-step">' +
|
||||
'<button data-testid="emit-type" @click="$emit(\'update:thumbnailType\', \'video\')" />' +
|
||||
'<button data-testid="emit-file" @click="$emit(\'update:thumbnailFile\', stubFile)" />' +
|
||||
'<button data-testid="emit-before-file" @click="$emit(\'update:comparisonBeforeFile\', stubFile)" />' +
|
||||
'<button data-testid="emit-after-file" @click="$emit(\'update:comparisonAfterFile\', stubFile)" />' +
|
||||
'<button data-testid="emit-clear" @click="$emit(\'clear\')" />' +
|
||||
'</div>',
|
||||
emits: [
|
||||
'update:thumbnailType',
|
||||
'update:thumbnailFile',
|
||||
'update:comparisonBeforeFile',
|
||||
'update:comparisonAfterFile',
|
||||
'clear'
|
||||
],
|
||||
setup() {
|
||||
return { stubFile: new File(['x'], 'stub.png') }
|
||||
}
|
||||
},
|
||||
ComfyHubProfilePromptPanel: {
|
||||
template:
|
||||
@@ -317,6 +337,93 @@ describe('ComfyHubPublishWizardContent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('thumbnail step handlers', () => {
|
||||
function lastFormUpdate(): Partial<ComfyHubPublishFormData> {
|
||||
const payload = onUpdateFormData.mock.calls.at(-1)?.[0]
|
||||
expect(payload).toBeTruthy()
|
||||
return payload as Partial<ComfyHubPublishFormData>
|
||||
}
|
||||
|
||||
it('clears every file/url field when thumbnail type changes', async () => {
|
||||
renderComponent({ currentStep: 'examples' })
|
||||
|
||||
await userEvent.click(screen.getByTestId('emit-type'))
|
||||
|
||||
expect(onUpdateFormData).toHaveBeenCalledWith({
|
||||
thumbnailType: 'video',
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonBeforeUrl: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null
|
||||
})
|
||||
})
|
||||
|
||||
it('clears thumbnailUrl when a new thumbnail file is chosen', async () => {
|
||||
renderComponent({ currentStep: 'examples' })
|
||||
|
||||
await userEvent.click(screen.getByTestId('emit-file'))
|
||||
|
||||
const call = lastFormUpdate()
|
||||
expect(call).toMatchObject({ thumbnailUrl: null })
|
||||
expect(call.thumbnailFile).toBeInstanceOf(File)
|
||||
})
|
||||
|
||||
it('clears comparisonBeforeUrl when a new comparison-before file is chosen', async () => {
|
||||
renderComponent({ currentStep: 'examples' })
|
||||
|
||||
await userEvent.click(screen.getByTestId('emit-before-file'))
|
||||
|
||||
const call = lastFormUpdate()
|
||||
expect(call).toMatchObject({ comparisonBeforeUrl: null })
|
||||
expect(call.comparisonBeforeFile).toBeInstanceOf(File)
|
||||
})
|
||||
|
||||
it('clears comparisonAfterUrl when a new comparison-after file is chosen', async () => {
|
||||
renderComponent({ currentStep: 'examples' })
|
||||
|
||||
await userEvent.click(screen.getByTestId('emit-after-file'))
|
||||
|
||||
const call = lastFormUpdate()
|
||||
expect(call).toMatchObject({ comparisonAfterUrl: null })
|
||||
expect(call.comparisonAfterFile).toBeInstanceOf(File)
|
||||
})
|
||||
|
||||
it('clears thumbnail file + url when child emits clear in image mode', async () => {
|
||||
renderComponent({
|
||||
currentStep: 'examples',
|
||||
formData: { ...createDefaultFormData(), thumbnailType: 'image' }
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('emit-clear'))
|
||||
|
||||
expect(onUpdateFormData).toHaveBeenCalledWith({
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null
|
||||
})
|
||||
})
|
||||
|
||||
it('clears both comparison files + urls when child emits clear in comparison mode', async () => {
|
||||
renderComponent({
|
||||
currentStep: 'examples',
|
||||
formData: {
|
||||
...createDefaultFormData(),
|
||||
thumbnailType: 'imageComparison'
|
||||
}
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByTestId('emit-clear'))
|
||||
|
||||
expect(onUpdateFormData).toHaveBeenCalledWith({
|
||||
comparisonBeforeFile: null,
|
||||
comparisonBeforeUrl: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('profileCreation step rendering', () => {
|
||||
it('shows profile creation form when on profileCreation step', () => {
|
||||
renderComponent({ currentStep: 'profileCreation' })
|
||||
|
||||
@@ -24,14 +24,14 @@
|
||||
>
|
||||
<ComfyHubThumbnailStep
|
||||
:thumbnail-type="formData.thumbnailType"
|
||||
@update:thumbnail-type="onUpdateFormData({ thumbnailType: $event })"
|
||||
@update:thumbnail-file="onUpdateFormData({ thumbnailFile: $event })"
|
||||
@update:comparison-before-file="
|
||||
onUpdateFormData({ comparisonBeforeFile: $event })
|
||||
"
|
||||
@update:comparison-after-file="
|
||||
onUpdateFormData({ comparisonAfterFile: $event })
|
||||
"
|
||||
:thumbnail-url="formData.thumbnailUrl"
|
||||
:comparison-before-url="formData.comparisonBeforeUrl"
|
||||
:comparison-after-url="formData.comparisonAfterUrl"
|
||||
@update:thumbnail-type="onUpdateThumbnailType"
|
||||
@update:thumbnail-file="onUpdateThumbnailFile"
|
||||
@update:comparison-before-file="onUpdateComparisonBeforeFile"
|
||||
@update:comparison-after-file="onUpdateComparisonAfterFile"
|
||||
@clear="onClearThumbnail"
|
||||
/>
|
||||
<ComfyHubExamplesStep
|
||||
:example-images="formData.exampleImages"
|
||||
@@ -140,6 +140,57 @@ const isPublishDisabled = computed(
|
||||
(isFinishStepVisible.value && !finishStepReady.value)
|
||||
)
|
||||
|
||||
function onUpdateThumbnailType(
|
||||
value: ComfyHubPublishFormData['thumbnailType']
|
||||
) {
|
||||
onUpdateFormData({
|
||||
thumbnailType: value,
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonBeforeUrl: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null
|
||||
})
|
||||
}
|
||||
|
||||
function onUpdateThumbnailFile(value: File | null) {
|
||||
onUpdateFormData({
|
||||
thumbnailFile: value,
|
||||
thumbnailUrl: null
|
||||
})
|
||||
}
|
||||
|
||||
function onUpdateComparisonBeforeFile(value: File | null) {
|
||||
onUpdateFormData({
|
||||
comparisonBeforeFile: value,
|
||||
comparisonBeforeUrl: null
|
||||
})
|
||||
}
|
||||
|
||||
function onUpdateComparisonAfterFile(value: File | null) {
|
||||
onUpdateFormData({
|
||||
comparisonAfterFile: value,
|
||||
comparisonAfterUrl: null
|
||||
})
|
||||
}
|
||||
|
||||
function onClearThumbnail() {
|
||||
if (formData.thumbnailType === 'imageComparison') {
|
||||
onUpdateFormData({
|
||||
comparisonBeforeFile: null,
|
||||
comparisonBeforeUrl: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null
|
||||
})
|
||||
return
|
||||
}
|
||||
onUpdateFormData({
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null
|
||||
})
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (isResolvingPublishAccess.value || isPublishing) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ComfyHubThumbnailStep from './ComfyHubThumbnailStep.vue'
|
||||
|
||||
type ThumbnailStepProps = ComponentProps<typeof ComfyHubThumbnailStep>
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/composables/useSliderFromMouse', () => ({
|
||||
useSliderFromMouse: () => ({ value: 50 })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/sharing/utils/validateFileSize', () => ({
|
||||
isFileTooLarge: () => false,
|
||||
MAX_IMAGE_SIZE_MB: 25,
|
||||
MAX_VIDEO_SIZE_MB: 100
|
||||
}))
|
||||
|
||||
function renderStep(
|
||||
props: Partial<ThumbnailStepProps> = {},
|
||||
callbacks: Partial<ThumbnailStepProps> = {}
|
||||
) {
|
||||
return render(ComfyHubThumbnailStep, {
|
||||
props: { ...props, ...callbacks },
|
||||
global: {
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('ComfyHubThumbnailStep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('prefilled URL preview', () => {
|
||||
it('renders an <img> using thumbnailUrl when no file is selected', () => {
|
||||
renderStep({ thumbnailUrl: 'https://cdn.example.com/thumb.png' })
|
||||
|
||||
const img = screen.getByAltText('comfyHubPublish.thumbnailPreview')
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img.getAttribute('src')).toBe('https://cdn.example.com/thumb.png')
|
||||
expect(
|
||||
screen.queryByLabelText('comfyHubPublish.videoPreview')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a <video> when thumbnailType is video and URL has a video extension', () => {
|
||||
renderStep({
|
||||
thumbnailType: 'video',
|
||||
thumbnailUrl: 'https://cdn.example.com/clip.mp4?token=abc'
|
||||
})
|
||||
|
||||
const video = screen.getByLabelText('comfyHubPublish.videoPreview')
|
||||
expect(video.tagName.toLowerCase()).toBe('video')
|
||||
expect(video.getAttribute('src')).toBe(
|
||||
'https://cdn.example.com/clip.mp4?token=abc'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders an <img> for video mode when URL points at an image (e.g. gif)', () => {
|
||||
renderStep({
|
||||
thumbnailType: 'video',
|
||||
thumbnailUrl: 'https://cdn.example.com/anim.gif'
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByAltText('comfyHubPublish.thumbnailPreview')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByLabelText('comfyHubPublish.videoPreview')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders both comparison previews when both URLs are provided', () => {
|
||||
renderStep({
|
||||
thumbnailType: 'imageComparison',
|
||||
comparisonBeforeUrl: 'https://cdn.example.com/before.png',
|
||||
comparisonAfterUrl: 'https://cdn.example.com/after.png'
|
||||
})
|
||||
|
||||
const beforeImgs = screen.getAllByAltText(
|
||||
'comfyHubPublish.uploadComparisonBeforePrompt'
|
||||
)
|
||||
const afterImgs = screen.getAllByAltText(
|
||||
'comfyHubPublish.uploadComparisonAfterPrompt'
|
||||
)
|
||||
expect(
|
||||
beforeImgs.some(
|
||||
(el) =>
|
||||
el.getAttribute('src') === 'https://cdn.example.com/before.png'
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
afterImgs.some(
|
||||
(el) => el.getAttribute('src') === 'https://cdn.example.com/after.png'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('shows upload prompt when no thumbnail file or URL is set', () => {
|
||||
renderStep()
|
||||
|
||||
expect(
|
||||
screen.queryByAltText('comfyHubPublish.thumbnailPreview')
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('comfyHubPublish.uploadPromptClickToBrowse')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear emits', () => {
|
||||
it('emits clear (image mode) when the user clicks Clear with a prefilled URL', async () => {
|
||||
const onClear = vi.fn()
|
||||
renderStep(
|
||||
{ thumbnailUrl: 'https://cdn.example.com/thumb.png' },
|
||||
{ onClear }
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.clear' }))
|
||||
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('emits clear (comparison mode) when at least one comparison URL is set', async () => {
|
||||
const onClear = vi.fn()
|
||||
renderStep(
|
||||
{
|
||||
thumbnailType: 'imageComparison',
|
||||
comparisonBeforeUrl: 'https://cdn.example.com/before.png'
|
||||
},
|
||||
{ onClear }
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'g.clear' }))
|
||||
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides Clear when there is no thumbnail content', () => {
|
||||
renderStep()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'g.clear' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -197,8 +197,16 @@ import { useDropZone, useObjectUrl } from '@vueuse/core'
|
||||
import { computed, reactive, ref, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { thumbnailType = 'image' } = defineProps<{
|
||||
const {
|
||||
thumbnailType = 'image',
|
||||
thumbnailUrl = null,
|
||||
comparisonBeforeUrl = null,
|
||||
comparisonAfterUrl = null
|
||||
} = defineProps<{
|
||||
thumbnailType?: ThumbnailType
|
||||
thumbnailUrl?: string | null
|
||||
comparisonBeforeUrl?: string | null
|
||||
comparisonAfterUrl?: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -206,6 +214,7 @@ const emit = defineEmits<{
|
||||
'update:thumbnailFile': [value: File | null]
|
||||
'update:comparisonBeforeFile': [value: File | null]
|
||||
'update:comparisonAfterFile': [value: File | null]
|
||||
clear: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -216,11 +225,9 @@ function isThumbnailType(value: string): value is ThumbnailType {
|
||||
|
||||
function handleThumbnailTypeChange(value: unknown) {
|
||||
if (typeof value === 'string' && isThumbnailType(value)) {
|
||||
thumbnailFile.value = null
|
||||
comparisonBeforeFile.value = null
|
||||
comparisonAfterFile.value = null
|
||||
emit('update:thumbnailFile', null)
|
||||
emit('update:comparisonBeforeFile', null)
|
||||
emit('update:comparisonAfterFile', null)
|
||||
emit('update:thumbnailType', value)
|
||||
}
|
||||
}
|
||||
@@ -258,8 +265,23 @@ const thumbnailOptions = [
|
||||
]
|
||||
|
||||
const thumbnailFile = shallowRef<File | null>(null)
|
||||
const thumbnailPreviewUrl = useObjectUrl(thumbnailFile)
|
||||
const isVideoFile = ref(false)
|
||||
const thumbnailObjectUrl = useObjectUrl(thumbnailFile)
|
||||
const thumbnailPreviewUrl = computed(
|
||||
() => thumbnailObjectUrl.value ?? thumbnailUrl ?? undefined
|
||||
)
|
||||
function urlLooksLikeVideo(url: string | null | undefined): boolean {
|
||||
if (!url) return false
|
||||
const path = url.split(/[?#]/, 1)[0] ?? ''
|
||||
return /\.(mp4|webm|mov|m4v|ogv|ogg)$/i.test(path)
|
||||
}
|
||||
|
||||
const isVideoFile = computed(() => {
|
||||
if (thumbnailFile.value) {
|
||||
return thumbnailFile.value.type.startsWith('video/')
|
||||
}
|
||||
if (thumbnailType !== 'video') return false
|
||||
return urlLooksLikeVideo(thumbnailUrl)
|
||||
})
|
||||
|
||||
function setThumbnailPreview(file: File) {
|
||||
const maxSize = file.type.startsWith('video/')
|
||||
@@ -267,15 +289,20 @@ function setThumbnailPreview(file: File) {
|
||||
: MAX_IMAGE_SIZE_MB
|
||||
if (isFileTooLarge(file, maxSize)) return
|
||||
thumbnailFile.value = file
|
||||
isVideoFile.value = file.type.startsWith('video/')
|
||||
emit('update:thumbnailFile', file)
|
||||
}
|
||||
|
||||
const comparisonBeforeFile = shallowRef<File | null>(null)
|
||||
const comparisonAfterFile = shallowRef<File | null>(null)
|
||||
const comparisonBeforeObjectUrl = useObjectUrl(comparisonBeforeFile)
|
||||
const comparisonAfterObjectUrl = useObjectUrl(comparisonAfterFile)
|
||||
const comparisonPreviewUrls = reactive({
|
||||
before: useObjectUrl(comparisonBeforeFile),
|
||||
after: useObjectUrl(comparisonAfterFile)
|
||||
before: computed(
|
||||
() => comparisonBeforeObjectUrl.value ?? comparisonBeforeUrl ?? undefined
|
||||
),
|
||||
after: computed(
|
||||
() => comparisonAfterObjectUrl.value ?? comparisonAfterUrl ?? undefined
|
||||
)
|
||||
})
|
||||
|
||||
const hasBothComparisonImages = computed(
|
||||
@@ -296,13 +323,10 @@ function clearAllPreviews() {
|
||||
if (thumbnailType === 'imageComparison') {
|
||||
comparisonBeforeFile.value = null
|
||||
comparisonAfterFile.value = null
|
||||
emit('update:comparisonBeforeFile', null)
|
||||
emit('update:comparisonAfterFile', null)
|
||||
return
|
||||
} else {
|
||||
thumbnailFile.value = null
|
||||
}
|
||||
|
||||
thumbnailFile.value = null
|
||||
emit('update:thumbnailFile', null)
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
function handleFileSelect(event: Event) {
|
||||
|
||||
@@ -58,8 +58,11 @@ function createFormData(
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonBeforeUrl: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {},
|
||||
@@ -187,6 +190,66 @@ describe('useComfyHubPublishSubmission', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('passes prefilled thumbnail URL through without re-uploading', async () => {
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: 'https://cdn.example.com/existing-thumb.png'
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockRequestAssetUploadUrl).not.toHaveBeenCalled()
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnailTokenOrUrl: 'https://cdn.example.com/existing-thumb.png'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uploads a newly chosen thumbnail file even when a prefill URL is also present', async () => {
|
||||
const file = new File(['new'], 'new-thumb.png', { type: 'image/png' })
|
||||
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: file,
|
||||
thumbnailUrl: 'https://cdn.example.com/old.png'
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockRequestAssetUploadUrl).toHaveBeenCalledTimes(1)
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnailTokenOrUrl: 'token-1'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('passes prefilled comparison URLs through for imageComparison thumbnails', async () => {
|
||||
const { submitToComfyHub } = useComfyHubPublishSubmission()
|
||||
await submitToComfyHub(
|
||||
createFormData({
|
||||
thumbnailType: 'imageComparison',
|
||||
comparisonBeforeFile: null,
|
||||
comparisonBeforeUrl: 'https://cdn.example.com/before.png',
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: 'https://cdn.example.com/after.png'
|
||||
})
|
||||
)
|
||||
|
||||
expect(mockRequestAssetUploadUrl).not.toHaveBeenCalled()
|
||||
expect(mockPublishWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thumbnailType: 'imageComparison',
|
||||
thumbnailTokenOrUrl: 'https://cdn.example.com/before.png',
|
||||
thumbnailComparisonTokenOrUrl: 'https://cdn.example.com/after.png'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when profile username is unavailable', async () => {
|
||||
mockProfile.value = null
|
||||
|
||||
|
||||
@@ -32,13 +32,24 @@ function getAssetIds(assets: AssetInfo[]): string[] {
|
||||
return assets.map((asset) => asset.id)
|
||||
}
|
||||
|
||||
function resolveThumbnailFile(
|
||||
interface ThumbnailSource {
|
||||
file: File | null
|
||||
url: string | null
|
||||
}
|
||||
|
||||
function resolveThumbnailSource(
|
||||
formData: ComfyHubPublishFormData
|
||||
): File | undefined {
|
||||
): ThumbnailSource {
|
||||
if (formData.thumbnailType === 'imageComparison') {
|
||||
return formData.comparisonBeforeFile ?? undefined
|
||||
return {
|
||||
file: formData.comparisonBeforeFile,
|
||||
url: formData.comparisonBeforeUrl
|
||||
}
|
||||
}
|
||||
return {
|
||||
file: formData.thumbnailFile,
|
||||
url: formData.thumbnailUrl
|
||||
}
|
||||
return formData.thumbnailFile ?? undefined
|
||||
}
|
||||
|
||||
export function useComfyHubPublishSubmission() {
|
||||
@@ -74,14 +85,15 @@ export function useComfyHubPublishSubmission() {
|
||||
await workflowShareService.getShareableAssets()
|
||||
)
|
||||
|
||||
const thumbnailFile = resolveThumbnailFile(formData)
|
||||
const thumbnailTokenOrUrl = thumbnailFile
|
||||
? await uploadFileAndGetToken(thumbnailFile)
|
||||
: undefined
|
||||
const thumbnail = resolveThumbnailSource(formData)
|
||||
const thumbnailTokenOrUrl = thumbnail.file
|
||||
? await uploadFileAndGetToken(thumbnail.file)
|
||||
: (thumbnail.url ?? undefined)
|
||||
const thumbnailComparisonTokenOrUrl =
|
||||
formData.thumbnailType === 'imageComparison' &&
|
||||
formData.comparisonAfterFile
|
||||
? await uploadFileAndGetToken(formData.comparisonAfterFile)
|
||||
formData.thumbnailType === 'imageComparison'
|
||||
? formData.comparisonAfterFile
|
||||
? await uploadFileAndGetToken(formData.comparisonAfterFile)
|
||||
: (formData.comparisonAfterUrl ?? undefined)
|
||||
: undefined
|
||||
|
||||
const sampleImageTokensOrUrls =
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyHubPublishFormData } from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
|
||||
const mockActiveWorkflow = vi.hoisted(() => ({
|
||||
value: { filename: 'my-workflow.json' } as { filename: string } | null
|
||||
}))
|
||||
@@ -12,7 +14,29 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const { useComfyHubPublishWizard } = await import('./useComfyHubPublishWizard')
|
||||
const { useComfyHubPublishWizard, cachePublishPrefill, getCachedPrefill } =
|
||||
await import('./useComfyHubPublishWizard')
|
||||
|
||||
function makeFormData(overrides: Partial<ComfyHubPublishFormData> = {}) {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
models: [],
|
||||
customNodes: [],
|
||||
thumbnailType: 'image' as const,
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonBeforeUrl: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {},
|
||||
...overrides
|
||||
} satisfies ComfyHubPublishFormData
|
||||
}
|
||||
|
||||
describe('useComfyHubPublishWizard', () => {
|
||||
beforeEach(() => {
|
||||
@@ -115,6 +139,268 @@ describe('useComfyHubPublishWizard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPrefill', () => {
|
||||
it('populates every form field from the prefill payload', () => {
|
||||
const { applyPrefill, formData } = useComfyHubPublishWizard()
|
||||
mockActiveWorkflow.value = { filename: 'my-workflow.json' }
|
||||
|
||||
applyPrefill({
|
||||
name: 'Cinematic Upscale',
|
||||
description: 'A polished workflow',
|
||||
tags: ['art', 'upscale'],
|
||||
models: ['sdxl'],
|
||||
customNodes: ['impact-pack'],
|
||||
thumbnailType: 'image',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.png',
|
||||
sampleImageUrls: ['https://cdn.example.com/sample-1.png'],
|
||||
tutorialUrl: 'https://youtube.com/watch?v=abc',
|
||||
metadata: { extended_description: 'long form' }
|
||||
})
|
||||
|
||||
expect(formData.value).toMatchObject({
|
||||
name: 'Cinematic Upscale',
|
||||
description: 'A polished workflow',
|
||||
tags: ['art', 'upscale'],
|
||||
models: ['sdxl'],
|
||||
customNodes: ['impact-pack'],
|
||||
thumbnailType: 'image',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.png',
|
||||
comparisonBeforeUrl: null,
|
||||
comparisonAfterUrl: null,
|
||||
tutorialUrl: 'https://youtube.com/watch?v=abc',
|
||||
metadata: { extended_description: 'long form' }
|
||||
})
|
||||
expect(formData.value.exampleImages).toHaveLength(1)
|
||||
expect(formData.value.exampleImages[0]?.url).toBe(
|
||||
'https://cdn.example.com/sample-1.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('routes thumbnail URLs into comparison slots when type is imageComparison', () => {
|
||||
const { applyPrefill, formData } = useComfyHubPublishWizard()
|
||||
|
||||
applyPrefill({
|
||||
thumbnailType: 'imageComparison',
|
||||
thumbnailUrl: 'https://cdn.example.com/before.png',
|
||||
thumbnailComparisonUrl: 'https://cdn.example.com/after.png'
|
||||
})
|
||||
|
||||
expect(formData.value.thumbnailType).toBe('imageComparison')
|
||||
expect(formData.value.thumbnailUrl).toBeNull()
|
||||
expect(formData.value.comparisonBeforeUrl).toBe(
|
||||
'https://cdn.example.com/before.png'
|
||||
)
|
||||
expect(formData.value.comparisonAfterUrl).toBe(
|
||||
'https://cdn.example.com/after.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('respects user input over prefill for fields the user has edited', () => {
|
||||
const { applyPrefill, formData } = useComfyHubPublishWizard()
|
||||
|
||||
formData.value.description = 'User-typed description'
|
||||
formData.value.tags = ['user-tag']
|
||||
formData.value.tutorialUrl = 'https://user.example.com'
|
||||
|
||||
applyPrefill({
|
||||
description: 'Prefill description',
|
||||
tags: ['prefill-tag'],
|
||||
tutorialUrl: 'https://prefill.example.com'
|
||||
})
|
||||
|
||||
expect(formData.value.description).toBe('User-typed description')
|
||||
expect(formData.value.tags).toEqual(['user-tag'])
|
||||
expect(formData.value.tutorialUrl).toBe('https://user.example.com')
|
||||
})
|
||||
|
||||
it('does not overwrite an already chosen thumbnail file with a prefill URL', () => {
|
||||
const { applyPrefill, formData } = useComfyHubPublishWizard()
|
||||
const userFile = new File(['x'], 'user.png', { type: 'image/png' })
|
||||
formData.value.thumbnailFile = userFile
|
||||
|
||||
applyPrefill({
|
||||
thumbnailUrl: 'https://cdn.example.com/prefilled.png'
|
||||
})
|
||||
|
||||
expect(formData.value.thumbnailFile?.name).toBe('user.png')
|
||||
expect(formData.value.thumbnailUrl).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves user-chosen models, customNodes, exampleImages, and metadata over prefill', () => {
|
||||
const { applyPrefill, formData } = useComfyHubPublishWizard()
|
||||
formData.value.models = ['user-model']
|
||||
formData.value.customNodes = ['user-node']
|
||||
formData.value.exampleImages = [
|
||||
{ id: 'user-1', url: 'https://user.example.com/a.png' }
|
||||
]
|
||||
formData.value.metadata = { user_key: 'user_value' }
|
||||
|
||||
applyPrefill({
|
||||
models: ['prefill-model'],
|
||||
customNodes: ['prefill-node'],
|
||||
sampleImageUrls: ['https://prefill.example.com/b.png'],
|
||||
metadata: { prefill_key: 'prefill_value' }
|
||||
})
|
||||
|
||||
expect(formData.value.models).toEqual(['user-model'])
|
||||
expect(formData.value.customNodes).toEqual(['user-node'])
|
||||
expect(formData.value.exampleImages).toEqual([
|
||||
{ id: 'user-1', url: 'https://user.example.com/a.png' }
|
||||
])
|
||||
expect(formData.value.metadata).toEqual({ user_key: 'user_value' })
|
||||
})
|
||||
|
||||
it('treats an empty prefill metadata object as no prefill', () => {
|
||||
const { applyPrefill, formData } = useComfyHubPublishWizard()
|
||||
formData.value.metadata = { user_key: 'keep-me' }
|
||||
|
||||
applyPrefill({ metadata: {} })
|
||||
|
||||
expect(formData.value.metadata).toEqual({ user_key: 'keep-me' })
|
||||
})
|
||||
|
||||
it('does not overwrite a prefilled thumbnailUrl on a second applyPrefill', () => {
|
||||
const { applyPrefill, formData } = useComfyHubPublishWizard()
|
||||
|
||||
applyPrefill({ thumbnailUrl: 'https://cdn.example.com/first.png' })
|
||||
applyPrefill({ thumbnailUrl: 'https://cdn.example.com/second.png' })
|
||||
|
||||
expect(formData.value.thumbnailUrl).toBe(
|
||||
'https://cdn.example.com/first.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not overwrite a chosen comparison-before file when applying comparison prefill', () => {
|
||||
const { applyPrefill, formData } = useComfyHubPublishWizard()
|
||||
const beforeFile = new File(['b'], 'before.png', { type: 'image/png' })
|
||||
formData.value.thumbnailType = 'imageComparison'
|
||||
formData.value.comparisonBeforeFile = beforeFile
|
||||
|
||||
applyPrefill({
|
||||
thumbnailType: 'imageComparison',
|
||||
thumbnailUrl: 'https://cdn.example.com/before-prefill.png',
|
||||
thumbnailComparisonUrl: 'https://cdn.example.com/after-prefill.png'
|
||||
})
|
||||
|
||||
expect(formData.value.comparisonBeforeFile?.name).toBe('before.png')
|
||||
expect(formData.value.comparisonBeforeUrl).toBeNull()
|
||||
expect(formData.value.comparisonAfterUrl).toBe(
|
||||
'https://cdn.example.com/after-prefill.png'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cachePublishPrefill / getCachedPrefill', () => {
|
||||
it('returns null when no prefill is cached for the workflow path', () => {
|
||||
expect(getCachedPrefill('workflows/never-cached.json')).toBeNull()
|
||||
})
|
||||
|
||||
it('roundtrips every cacheable form field through extractPrefillFromFormData', () => {
|
||||
const path = 'workflows/cached-full.json'
|
||||
cachePublishPrefill(
|
||||
path,
|
||||
makeFormData({
|
||||
name: 'My Workflow',
|
||||
description: 'Cached description',
|
||||
tags: ['art'],
|
||||
models: ['sdxl'],
|
||||
customNodes: ['impact-pack'],
|
||||
thumbnailType: 'image',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.png',
|
||||
exampleImages: [
|
||||
{ id: 'a', url: 'https://cdn.example.com/sample.png' },
|
||||
{ id: 'b', url: 'blob:https://localhost/abc' },
|
||||
{ id: 'c', url: '' }
|
||||
],
|
||||
tutorialUrl: 'https://youtube.com/abc',
|
||||
metadata: { extra: 'value' }
|
||||
})
|
||||
)
|
||||
|
||||
expect(getCachedPrefill(path)).toMatchObject({
|
||||
name: 'My Workflow',
|
||||
description: 'Cached description',
|
||||
tags: ['art'],
|
||||
models: ['sdxl'],
|
||||
customNodes: ['impact-pack'],
|
||||
thumbnailType: 'image',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.png',
|
||||
sampleImageUrls: ['https://cdn.example.com/sample.png'],
|
||||
tutorialUrl: 'https://youtube.com/abc',
|
||||
metadata: { extra: 'value' }
|
||||
})
|
||||
})
|
||||
|
||||
it('routes comparison-before URL into thumbnailUrl when type is imageComparison', () => {
|
||||
const path = 'workflows/cached-comparison.json'
|
||||
cachePublishPrefill(
|
||||
path,
|
||||
makeFormData({
|
||||
thumbnailType: 'imageComparison',
|
||||
comparisonBeforeUrl: 'https://cdn.example.com/before.png',
|
||||
comparisonAfterUrl: 'https://cdn.example.com/after.png'
|
||||
})
|
||||
)
|
||||
|
||||
const cached = getCachedPrefill(path)
|
||||
expect(cached?.thumbnailUrl).toBe('https://cdn.example.com/before.png')
|
||||
expect(cached?.thumbnailComparisonUrl).toBe(
|
||||
'https://cdn.example.com/after.png'
|
||||
)
|
||||
})
|
||||
|
||||
it('omits sampleImageUrls when every example URL is blob: or empty', () => {
|
||||
const path = 'workflows/cached-blobs.json'
|
||||
cachePublishPrefill(
|
||||
path,
|
||||
makeFormData({
|
||||
exampleImages: [
|
||||
{ id: 'a', url: 'blob:https://localhost/x' },
|
||||
{ id: 'b', url: '' }
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
expect(getCachedPrefill(path)?.sampleImageUrls).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits empty arrays/strings/metadata fields', () => {
|
||||
const path = 'workflows/cached-minimal.json'
|
||||
cachePublishPrefill(path, makeFormData({ thumbnailType: 'image' }))
|
||||
|
||||
const cached = getCachedPrefill(path)
|
||||
expect(cached?.thumbnailType).toBe('image')
|
||||
expect(cached?.name).toBeUndefined()
|
||||
expect(cached?.description).toBeUndefined()
|
||||
expect(cached?.tags).toBeUndefined()
|
||||
expect(cached?.models).toBeUndefined()
|
||||
expect(cached?.customNodes).toBeUndefined()
|
||||
expect(cached?.thumbnailUrl).toBeUndefined()
|
||||
expect(cached?.thumbnailComparisonUrl).toBeUndefined()
|
||||
expect(cached?.sampleImageUrls).toBeUndefined()
|
||||
expect(cached?.tutorialUrl).toBeUndefined()
|
||||
expect(cached?.metadata).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clones tags/models/metadata so later form mutations do not leak into the cache', () => {
|
||||
const tags = ['t1']
|
||||
const models = ['m1']
|
||||
const metadata = { k: 'v' }
|
||||
const path = 'workflows/cache-cloning.json'
|
||||
|
||||
cachePublishPrefill(path, makeFormData({ tags, models, metadata }))
|
||||
tags.push('mutated')
|
||||
models.push('mutated')
|
||||
metadata.k = 'mutated'
|
||||
|
||||
expect(getCachedPrefill(path)).toMatchObject({
|
||||
tags: ['t1'],
|
||||
models: ['m1'],
|
||||
metadata: { k: 'v' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('profile creation step', () => {
|
||||
it('navigates to profileCreation step', () => {
|
||||
const { currentStep, openProfileCreationStep } =
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
} from '@/platform/workflow/sharing/types/comfyHubTypes'
|
||||
import type { PublishPrefill } from '@/platform/workflow/sharing/types/shareTypes'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { normalizeTags } from '@/platform/workflow/sharing/utils/normalizeTags'
|
||||
|
||||
const PUBLISH_STEPS = [
|
||||
'describe',
|
||||
@@ -32,8 +31,11 @@ function createDefaultFormData(): ComfyHubPublishFormData {
|
||||
customNodes: [],
|
||||
thumbnailType: 'image',
|
||||
thumbnailFile: null,
|
||||
thumbnailUrl: null,
|
||||
comparisonBeforeFile: null,
|
||||
comparisonBeforeUrl: null,
|
||||
comparisonAfterFile: null,
|
||||
comparisonAfterUrl: null,
|
||||
exampleImages: [],
|
||||
tutorialUrl: '',
|
||||
metadata: {}
|
||||
@@ -44,16 +46,40 @@ function createExampleImagesFromUrls(urls: string[]): ExampleImage[] {
|
||||
return urls.map((url) => ({ id: uuidv4(), url }))
|
||||
}
|
||||
|
||||
function nonBlobUrlsFromExampleImages(
|
||||
exampleImages: ComfyHubPublishFormData['exampleImages']
|
||||
): string[] | undefined {
|
||||
const urls = exampleImages
|
||||
.map((img) => img.url)
|
||||
.filter((url) => url.length > 0 && !url.startsWith('blob:'))
|
||||
return urls.length > 0 ? urls : undefined
|
||||
}
|
||||
|
||||
function extractPrefillFromFormData(
|
||||
formData: ComfyHubPublishFormData
|
||||
): PublishPrefill {
|
||||
return {
|
||||
name: formData.name || undefined,
|
||||
description: formData.description || undefined,
|
||||
tags: formData.tags.length > 0 ? normalizeTags(formData.tags) : undefined,
|
||||
tags: formData.tags.length > 0 ? [...formData.tags] : undefined,
|
||||
models: formData.models.length > 0 ? [...formData.models] : undefined,
|
||||
customNodes:
|
||||
formData.customNodes.length > 0 ? [...formData.customNodes] : undefined,
|
||||
thumbnailType: formData.thumbnailType,
|
||||
sampleImageUrls: formData.exampleImages
|
||||
.map((img) => img.url)
|
||||
.filter((url) => !url.startsWith('blob:'))
|
||||
thumbnailUrl:
|
||||
formData.thumbnailType !== 'imageComparison'
|
||||
? (formData.thumbnailUrl ?? undefined)
|
||||
: (formData.comparisonBeforeUrl ?? undefined),
|
||||
thumbnailComparisonUrl:
|
||||
formData.thumbnailType === 'imageComparison'
|
||||
? (formData.comparisonAfterUrl ?? undefined)
|
||||
: undefined,
|
||||
sampleImageUrls: nonBlobUrlsFromExampleImages(formData.exampleImages),
|
||||
tutorialUrl: formData.tutorialUrl || undefined,
|
||||
metadata:
|
||||
Object.keys(formData.metadata).length > 0
|
||||
? { ...formData.metadata }
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,8 +121,18 @@ export function useComfyHubPublishWizard() {
|
||||
function applyPrefill(prefill: PublishPrefill) {
|
||||
const defaults = createDefaultFormData()
|
||||
const current = formData.value
|
||||
const resolvedThumbnailType =
|
||||
current.thumbnailType === defaults.thumbnailType
|
||||
? (prefill.thumbnailType ?? current.thumbnailType)
|
||||
: current.thumbnailType
|
||||
const isComparison = resolvedThumbnailType === 'imageComparison'
|
||||
|
||||
formData.value = {
|
||||
...current,
|
||||
name:
|
||||
current.name === defaults.name
|
||||
? (prefill.name ?? current.name)
|
||||
: current.name,
|
||||
description:
|
||||
current.description === defaults.description
|
||||
? (prefill.description ?? current.description)
|
||||
@@ -105,14 +141,47 @@ export function useComfyHubPublishWizard() {
|
||||
current.tags.length === 0 && prefill.tags?.length
|
||||
? prefill.tags
|
||||
: current.tags,
|
||||
thumbnailType:
|
||||
current.thumbnailType === defaults.thumbnailType
|
||||
? (prefill.thumbnailType ?? current.thumbnailType)
|
||||
: current.thumbnailType,
|
||||
models:
|
||||
current.models.length === 0 && prefill.models?.length
|
||||
? prefill.models
|
||||
: current.models,
|
||||
customNodes:
|
||||
current.customNodes.length === 0 && prefill.customNodes?.length
|
||||
? prefill.customNodes
|
||||
: current.customNodes,
|
||||
thumbnailType: resolvedThumbnailType,
|
||||
thumbnailUrl:
|
||||
!isComparison &&
|
||||
current.thumbnailFile === null &&
|
||||
current.thumbnailUrl === null
|
||||
? (prefill.thumbnailUrl ?? null)
|
||||
: current.thumbnailUrl,
|
||||
comparisonBeforeUrl:
|
||||
isComparison &&
|
||||
current.comparisonBeforeFile === null &&
|
||||
current.comparisonBeforeUrl === null
|
||||
? (prefill.thumbnailUrl ?? null)
|
||||
: current.comparisonBeforeUrl,
|
||||
comparisonAfterUrl:
|
||||
isComparison &&
|
||||
current.comparisonAfterFile === null &&
|
||||
current.comparisonAfterUrl === null
|
||||
? (prefill.thumbnailComparisonUrl ?? null)
|
||||
: current.comparisonAfterUrl,
|
||||
exampleImages:
|
||||
current.exampleImages.length === 0 && prefill.sampleImageUrls?.length
|
||||
? createExampleImagesFromUrls(prefill.sampleImageUrls)
|
||||
: current.exampleImages
|
||||
: current.exampleImages,
|
||||
tutorialUrl:
|
||||
current.tutorialUrl === defaults.tutorialUrl
|
||||
? (prefill.tutorialUrl ?? current.tutorialUrl)
|
||||
: current.tutorialUrl,
|
||||
metadata:
|
||||
Object.keys(current.metadata).length === 0 &&
|
||||
prefill.metadata &&
|
||||
Object.keys(prefill.metadata).length > 0
|
||||
? { ...prefill.metadata }
|
||||
: current.metadata
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,23 @@ export const zPublishRecordResponse = z.object({
|
||||
assets: z.array(zAssetInfo).optional()
|
||||
})
|
||||
|
||||
const zHubLabelRefOrString = z.union([
|
||||
z.string(),
|
||||
z.object({ name: z.string(), display_name: z.string().optional() })
|
||||
])
|
||||
|
||||
export const zHubWorkflowPrefillResponse = z.object({
|
||||
name: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
tags: z.array(z.string()).nullish(),
|
||||
tags: z.array(zHubLabelRefOrString).nullish(),
|
||||
models: z.array(zHubLabelRefOrString).nullish(),
|
||||
custom_nodes: z.array(zHubLabelRefOrString).nullish(),
|
||||
sample_image_urls: z.array(z.string()).nullish(),
|
||||
thumbnail_type: z.enum(['image', 'video', 'image_comparison']).nullish(),
|
||||
thumbnail_url: z.string().nullish(),
|
||||
thumbnail_comparison_url: z.string().nullish()
|
||||
thumbnail_comparison_url: z.string().nullish(),
|
||||
tutorial_url: z.string().nullish(),
|
||||
metadata: z.record(z.string(), z.unknown()).nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -199,6 +199,177 @@ describe(useWorkflowShareService, () => {
|
||||
expect(mockFetchApi).toHaveBeenNthCalledWith(2, '/hub/workflows/wf-prefill')
|
||||
})
|
||||
|
||||
it('maps every prefill field from a full hub workflow detail response', async () => {
|
||||
mockFetchApi.mockImplementation(async (path: string) => {
|
||||
if (path === '/userdata/wf-full/publish') {
|
||||
return mockJsonResponse({
|
||||
workflow_id: 'wf-full',
|
||||
share_id: 'wf-full',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: true
|
||||
})
|
||||
}
|
||||
|
||||
if (path === '/hub/workflows/wf-full') {
|
||||
return mockJsonResponse({
|
||||
name: 'Full Workflow',
|
||||
description: 'Everything filled in',
|
||||
tags: [
|
||||
{ name: 'text-to-image', display_name: 'Text to Image' },
|
||||
{ name: 'upscale', display_name: 'Upscale' }
|
||||
],
|
||||
models: [{ name: 'sdxl', display_name: 'SDXL' }],
|
||||
custom_nodes: [
|
||||
{ name: 'comfyui-impact-pack', display_name: 'Impact Pack' }
|
||||
],
|
||||
thumbnail_type: 'image',
|
||||
thumbnail_url: 'https://cdn.example.com/thumb.png',
|
||||
thumbnail_comparison_url: 'https://cdn.example.com/after.png',
|
||||
sample_image_urls: ['https://cdn.example.com/sample-1.png'],
|
||||
tutorial_url: 'https://youtube.com/watch?v=abc',
|
||||
metadata: { extended_description: 'long form text' }
|
||||
})
|
||||
}
|
||||
|
||||
return mockJsonResponse({}, false, 404)
|
||||
})
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-full')
|
||||
|
||||
expect(status.isPublished).toBe(true)
|
||||
expect(status.prefill).toEqual({
|
||||
name: 'Full Workflow',
|
||||
description: 'Everything filled in',
|
||||
tags: ['Text to Image', 'Upscale'],
|
||||
models: ['SDXL'],
|
||||
customNodes: ['Impact Pack'],
|
||||
thumbnailType: 'image',
|
||||
thumbnailUrl: 'https://cdn.example.com/thumb.png',
|
||||
thumbnailComparisonUrl: 'https://cdn.example.com/after.png',
|
||||
sampleImageUrls: ['https://cdn.example.com/sample-1.png'],
|
||||
tutorialUrl: 'https://youtube.com/watch?v=abc',
|
||||
metadata: { extended_description: 'long form text' }
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to label slug when display_name is missing', async () => {
|
||||
mockFetchApi.mockImplementation(async (path: string) => {
|
||||
if (path === '/userdata/wf-fallback/publish') {
|
||||
return mockJsonResponse({
|
||||
workflow_id: 'wf-fallback',
|
||||
share_id: 'wf-fallback',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: true
|
||||
})
|
||||
}
|
||||
|
||||
if (path === '/hub/workflows/wf-fallback') {
|
||||
return mockJsonResponse({
|
||||
tags: [{ name: 'realism' }, { name: 'portrait' }]
|
||||
})
|
||||
}
|
||||
|
||||
return mockJsonResponse({}, false, 404)
|
||||
})
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-fallback')
|
||||
|
||||
expect(status.prefill).toEqual({ tags: ['realism', 'portrait'] })
|
||||
})
|
||||
|
||||
it('accepts string-shaped tag arrays for backward compatibility', async () => {
|
||||
mockFetchApi.mockImplementation(async (path: string) => {
|
||||
if (path === '/userdata/wf-strings/publish') {
|
||||
return mockJsonResponse({
|
||||
workflow_id: 'wf-strings',
|
||||
share_id: 'wf-strings',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: true
|
||||
})
|
||||
}
|
||||
|
||||
if (path === '/hub/workflows/wf-strings') {
|
||||
return mockJsonResponse({
|
||||
tags: ['portrait', 'realism'],
|
||||
models: ['flux'],
|
||||
custom_nodes: []
|
||||
})
|
||||
}
|
||||
|
||||
return mockJsonResponse({}, false, 404)
|
||||
})
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-strings')
|
||||
|
||||
expect(status.prefill).toEqual({
|
||||
tags: ['portrait', 'realism'],
|
||||
models: ['flux']
|
||||
})
|
||||
})
|
||||
|
||||
it('drops empty arrays and empty metadata so prefill is null when hub details are blank', async () => {
|
||||
mockFetchApi.mockImplementation(async (path: string) => {
|
||||
if (path === '/userdata/wf-blank/publish') {
|
||||
return mockJsonResponse({
|
||||
workflow_id: 'wf-blank',
|
||||
share_id: 'wf-blank',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: true
|
||||
})
|
||||
}
|
||||
|
||||
if (path === '/hub/workflows/wf-blank') {
|
||||
return mockJsonResponse({
|
||||
tags: [],
|
||||
models: [],
|
||||
custom_nodes: [],
|
||||
metadata: {}
|
||||
})
|
||||
}
|
||||
|
||||
return mockJsonResponse({}, false, 404)
|
||||
})
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-blank')
|
||||
|
||||
expect(status.prefill).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to label name when display_name is whitespace-only and drops blank refs', async () => {
|
||||
mockFetchApi.mockImplementation(async (path: string) => {
|
||||
if (path === '/userdata/wf-ws/publish') {
|
||||
return mockJsonResponse({
|
||||
workflow_id: 'wf-ws',
|
||||
share_id: 'wf-ws',
|
||||
publish_time: '2026-02-23T00:00:00Z',
|
||||
listed: true
|
||||
})
|
||||
}
|
||||
|
||||
if (path === '/hub/workflows/wf-ws') {
|
||||
return mockJsonResponse({
|
||||
tags: [
|
||||
{ name: 'realism', display_name: ' ' },
|
||||
{ name: ' ', display_name: ' ' },
|
||||
{ name: 'portrait', display_name: 'Portrait' }
|
||||
],
|
||||
models: [{ name: '', display_name: '' }]
|
||||
})
|
||||
}
|
||||
|
||||
return mockJsonResponse({}, false, 404)
|
||||
})
|
||||
|
||||
const service = useWorkflowShareService()
|
||||
const status = await service.getPublishStatus('wf-ws')
|
||||
|
||||
expect(status.prefill).toEqual({ tags: ['realism', 'Portrait'] })
|
||||
})
|
||||
|
||||
it('returns null prefill when hub workflow details are unavailable', async () => {
|
||||
mockFetchApi.mockImplementation(async (path: string) => {
|
||||
if (path === '/userdata/wf-no-meta/publish') {
|
||||
|
||||
@@ -39,29 +39,82 @@ function mapApiThumbnailType(
|
||||
return value
|
||||
}
|
||||
|
||||
type LabelRefOrString = string | { name: string; display_name?: string }
|
||||
|
||||
interface PrefillMetadataFields {
|
||||
name?: string | null
|
||||
description?: string | null
|
||||
tags?: string[] | null
|
||||
tags?: LabelRefOrString[] | null
|
||||
models?: LabelRefOrString[] | null
|
||||
custom_nodes?: LabelRefOrString[] | null
|
||||
thumbnail_type?: 'image' | 'video' | 'image_comparison' | null
|
||||
thumbnail_url?: string | null
|
||||
thumbnail_comparison_url?: string | null
|
||||
sample_image_urls?: string[] | null
|
||||
tutorial_url?: string | null
|
||||
metadata?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function labelRefsToDisplayNames(
|
||||
refs: LabelRefOrString[] | null | undefined
|
||||
): string[] | undefined {
|
||||
if (!refs?.length) return undefined
|
||||
const labels = refs
|
||||
.map((ref) => {
|
||||
if (typeof ref === 'string') return ref
|
||||
const display = ref.display_name?.trim()
|
||||
return display && display.length > 0 ? display : ref.name
|
||||
})
|
||||
.filter((label) => label.trim().length > 0)
|
||||
return labels.length > 0 ? labels : undefined
|
||||
}
|
||||
|
||||
function extractPrefill(fields: PrefillMetadataFields): PublishPrefill | null {
|
||||
const name = fields.name ?? undefined
|
||||
const description = fields.description ?? undefined
|
||||
const tags = fields.tags ?? undefined
|
||||
const tags = labelRefsToDisplayNames(fields.tags)
|
||||
const models = labelRefsToDisplayNames(fields.models)
|
||||
const customNodes = labelRefsToDisplayNames(fields.custom_nodes)
|
||||
const thumbnailType = mapApiThumbnailType(fields.thumbnail_type)
|
||||
const thumbnailUrl = fields.thumbnail_url ?? undefined
|
||||
const thumbnailComparisonUrl = fields.thumbnail_comparison_url ?? undefined
|
||||
const sampleImageUrls = fields.sample_image_urls ?? undefined
|
||||
const tutorialUrl = fields.tutorial_url ?? undefined
|
||||
const metadata =
|
||||
fields.metadata && Object.keys(fields.metadata).length > 0
|
||||
? fields.metadata
|
||||
: undefined
|
||||
|
||||
if (
|
||||
!description &&
|
||||
!tags?.length &&
|
||||
!thumbnailType &&
|
||||
!sampleImageUrls?.length
|
||||
) {
|
||||
const hasAnything =
|
||||
name ||
|
||||
description ||
|
||||
tags?.length ||
|
||||
models?.length ||
|
||||
customNodes?.length ||
|
||||
thumbnailType ||
|
||||
thumbnailUrl ||
|
||||
thumbnailComparisonUrl ||
|
||||
sampleImageUrls?.length ||
|
||||
tutorialUrl ||
|
||||
metadata
|
||||
|
||||
if (!hasAnything) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { description, tags, thumbnailType, sampleImageUrls }
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
tags,
|
||||
models,
|
||||
customNodes,
|
||||
thumbnailType,
|
||||
thumbnailUrl,
|
||||
thumbnailComparisonUrl,
|
||||
sampleImageUrls,
|
||||
tutorialUrl,
|
||||
metadata
|
||||
}
|
||||
}
|
||||
|
||||
function decodeHubWorkflowPrefill(payload: unknown): PublishPrefill | null {
|
||||
|
||||
@@ -14,8 +14,11 @@ export interface ComfyHubPublishFormData {
|
||||
customNodes: string[]
|
||||
thumbnailType: ThumbnailType
|
||||
thumbnailFile: File | null
|
||||
thumbnailUrl: string | null
|
||||
comparisonBeforeFile: File | null
|
||||
comparisonBeforeUrl: string | null
|
||||
comparisonAfterFile: File | null
|
||||
comparisonAfterUrl: string | null
|
||||
exampleImages: ExampleImage[]
|
||||
tutorialUrl: string
|
||||
metadata: Record<string, unknown>
|
||||
|
||||
@@ -12,10 +12,17 @@ export interface WorkflowPublishResult {
|
||||
}
|
||||
|
||||
export interface PublishPrefill {
|
||||
name?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
models?: string[]
|
||||
customNodes?: string[]
|
||||
thumbnailType?: ThumbnailType
|
||||
thumbnailUrl?: string
|
||||
thumbnailComparisonUrl?: string
|
||||
sampleImageUrls?: string[]
|
||||
tutorialUrl?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type WorkflowPublishStatus =
|
||||
|
||||
Reference in New Issue
Block a user