Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -4975,6 +4975,22 @@
}
},
"nudge": {
"title": "Your first output. Here's what's next.",
"body": "Each one starts from the image you just made.",
"animate": {
"title": "Animate it",
"detail": "Image to video · 5s"
},
"upscale": {
"title": "Upscale it",
"detail": "Same image, more detail",
"badge": "4x"
},
"restyle": {
"title": "Restyle with Nano Banana",
"detail": "Partner model · Any paid plan"
},
"loadFailed": "That template couldn't be loaded. Please try again.",
"ran": {
"title": "That was one of hundreds",
"body": "You just made your first. Explore what else you can build."
Expand All @@ -4984,7 +5000,7 @@
"body": "Browse the templates and pick something to build."
},
"dismiss": "Not now",
"explore": "Explore templates"
"explore": "Browse all templates"
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'

import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { app } from '@/scripts/app'

async function flushPromises() {
await new Promise((r) => setTimeout(r, 0))
Expand Down Expand Up @@ -74,6 +75,27 @@ describe('useTemplateWorkflows', () => {
mockWorkflowTemplatesStore = {
isLoaded: false,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
getTemplateByName: vi.fn((name: string) =>
name === 'template1'
? {
name,
mediaType: 'image',
mediaSubtype: 'jpg',
sourceModule: 'default',
description: 'Template 1 description',
io: {
inputs: [
{
nodeId: 2,
nodeType: 'LoadImage',
file: 'starter.png',
mediaType: 'image'
}
]
}
}
: undefined
),
groupedTemplates: [
{
label: 'ComfyUI Examples',
Expand Down Expand Up @@ -298,6 +320,47 @@ describe('useTemplateWorkflows', () => {
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
})

it('seeds a result into the template before loading the workflow', async () => {
const { loadWorkflowTemplate } = useTemplateWorkflows()
mockWorkflowTemplatesStore.isLoaded = true
vi.mocked(fetch).mockResolvedValueOnce({
json: vi.fn().mockResolvedValue({
nodes: [
{
id: 2,
type: 'LoadImage',
widgets_values: ['starter.png', 'image']
}
]
})
} as Partial<Response> as Response)

const result = await loadWorkflowTemplate('template1', 'default', {
input: {
filename: 'first-output.png',
subfolder: 'tour',
type: 'output'
}
})

expect(result).toBe(true)
expect(app.loadGraphData).toHaveBeenCalledWith(
{
nodes: [
{
id: 2,
type: 'LoadImage',
widgets_values: ['tour/first-output.png [output]', 'image']
}
]
},
true,
true,
'template1',
{ openSource: 'template' }
)
})

it('tracks template telemetry on load in cloud builds', async () => {
const { loadWorkflowTemplate } = useTemplateWorkflows()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,18 @@ import type {
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { replaceTemplateImageInput } from '@/platform/workflow/templates/utils/templateWorkflowTransforms'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItem } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'

interface LoadWorkflowTemplateOptions {
input?: ResultItem
transformWorkflow?: (workflow: ComfyWorkflowJSON) => ComfyWorkflowJSON
}

export function useTemplateWorkflows() {
const { t } = useI18n()
const workflowTemplatesStore = useWorkflowTemplatesStore()
Expand Down Expand Up @@ -97,11 +105,15 @@ export function useTemplateWorkflows() {
/**
* Loads a workflow template
*/
const loadWorkflowTemplate = async (id: string, sourceModule: string) => {
const loadWorkflowTemplate = async (
id: string,
sourceModule: string,
options: LoadWorkflowTemplateOptions = {}
) => {
if (!isTemplatesLoaded.value) return false

loadingTemplateId.value = id
let json
let json: ComfyWorkflowJSON

try {
// Handle "All" category as a special case
Expand All @@ -126,6 +138,14 @@ export function useTemplateWorkflows() {
// Regular case for normal categories
json = await fetchTemplateJson(id, sourceModule)

if (options.input) {
const template = workflowTemplatesStore.getTemplateByName(id)
if (!template || template.sourceModule !== sourceModule) return false
json = replaceTemplateImageInput(json, template, options.input)
}

if (options.transformWorkflow) json = options.transformWorkflow(json)

const workflowName =
sourceModule === 'default'
? t(`templateWorkflows.template.${id}`, id)
Expand Down Expand Up @@ -153,7 +173,10 @@ export function useTemplateWorkflows() {
/**
* Fetches template JSON from the appropriate endpoint
*/
const fetchTemplateJson = async (id: string, sourceModule: string) => {
const fetchTemplateJson = async (
id: string,
sourceModule: string
): Promise<ComfyWorkflowJSON> => {
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())
Expand Down
14 changes: 14 additions & 0 deletions src/platform/workflow/templates/types/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ export interface LogoInfo {
position?: string
}

interface TemplateMediaInfo {
nodeId: string | number
nodeType: string
file: string
mediaType: string
}

interface TemplateIoInfo {
inputs?: TemplateMediaInfo[]
outputs?: TemplateMediaInfo[]
}

export interface TemplateInfo {
name: string
/**
Expand Down Expand Up @@ -67,6 +79,8 @@ export interface TemplateInfo {
* Logo overlays to display on the template thumbnail.
*/
logos?: LogoInfo[]
/** Declared media entry and exit points for continuing from another result. */
io?: TemplateIoInfo
}

export enum TemplateIncludeOnDistributionEnum {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'

import type { TemplateInfo } from '../types/template'
import {
replaceTemplateImageInput,
replaceUniqueTemplateWidgetValue
} from './templateWorkflowTransforms'

const template: TemplateInfo = {
name: 'image-template',
description: 'Image template',
mediaType: 'image',
mediaSubtype: 'png',
io: {
inputs: [
{
nodeId: 2,
nodeType: 'LoadImage',
file: 'starter.png',
mediaType: 'image'
}
]
}
}

describe('template workflow transforms', () => {
it('seeds a declared image input with an output asset', () => {
const workflow = {
nodes: [
{
id: 2,
type: 'LoadImage',
widgets_values: ['starter.png', 'image']
}
]
}

const continued = replaceTemplateImageInput(workflow, template, {
filename: 'first-output.png',
subfolder: 'tour',
type: 'output'
})

expect(continued.nodes[0].widgets_values).toEqual([
'tour/first-output.png [output]',
'image'
])
expect(workflow.nodes[0].widgets_values).toEqual(['starter.png', 'image'])
})

it('configures a unique template widget without relying on its node id', () => {
const workflow = {
nodes: [
{
id: 37,
type: 'ImageScaleBy',
widgets_values: ['lanczos', 2]
}
]
}

const configured = replaceUniqueTemplateWidgetValue(
workflow,
'ImageScaleBy',
2,
4
)

expect(configured.nodes[0].widgets_values).toEqual(['lanczos', 4])
})

it('rejects drift between declared input metadata and workflow widgets', () => {
const workflow = {
nodes: [
{
id: 2,
type: 'LoadImage',
widgets_values: ['different.png', 'image']
}
]
}

expect(() =>
replaceTemplateImageInput(workflow, template, {
filename: 'first-output.png'
})
).toThrow('Expected one matching template widget value')
})
})
Loading
Loading