Skip to content

Commit 7838ebe

Browse files
committed
feat: add post-first-run discovery actions
1 parent 32596ad commit 7838ebe

10 files changed

Lines changed: 698 additions & 227 deletions

File tree

src/locales/en/main.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4975,6 +4975,22 @@
49754975
}
49764976
},
49774977
"nudge": {
4978+
"title": "Your first output. Here's what's next.",
4979+
"body": "Each one starts from the image you just made.",
4980+
"animate": {
4981+
"title": "Animate it",
4982+
"detail": "Image to video · 5s"
4983+
},
4984+
"upscale": {
4985+
"title": "Upscale it",
4986+
"detail": "Same image, more detail",
4987+
"badge": "4x"
4988+
},
4989+
"restyle": {
4990+
"title": "Restyle with Nano Banana",
4991+
"detail": "Partner model · Any paid plan"
4992+
},
4993+
"loadFailed": "That template couldn't be loaded. Please try again.",
49784994
"ran": {
49794995
"title": "That was one of hundreds",
49804996
"body": "You just made your first. Explore what else you can build."
@@ -4984,7 +5000,7 @@
49845000
"body": "Browse the templates and pick something to build."
49855001
},
49865002
"dismiss": "Not now",
4987-
"explore": "Explore templates"
5003+
"explore": "Browse all templates"
49885004
}
49895005
}
49905006
}

src/platform/workflow/templates/composables/useTemplateWorkflows.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
22

33
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
44
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
5+
import { app } from '@/scripts/app'
56

67
async function flushPromises() {
78
await new Promise((r) => setTimeout(r, 0))
@@ -74,6 +75,27 @@ describe('useTemplateWorkflows', () => {
7475
mockWorkflowTemplatesStore = {
7576
isLoaded: false,
7677
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
78+
getTemplateByName: vi.fn((name: string) =>
79+
name === 'template1'
80+
? {
81+
name,
82+
mediaType: 'image',
83+
mediaSubtype: 'jpg',
84+
sourceModule: 'default',
85+
description: 'Template 1 description',
86+
io: {
87+
inputs: [
88+
{
89+
nodeId: 2,
90+
nodeType: 'LoadImage',
91+
file: 'starter.png',
92+
mediaType: 'image'
93+
}
94+
]
95+
}
96+
}
97+
: undefined
98+
),
7799
groupedTemplates: [
78100
{
79101
label: 'ComfyUI Examples',
@@ -298,6 +320,47 @@ describe('useTemplateWorkflows', () => {
298320
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
299321
})
300322

323+
it('seeds a result into the template before loading the workflow', async () => {
324+
const { loadWorkflowTemplate } = useTemplateWorkflows()
325+
mockWorkflowTemplatesStore.isLoaded = true
326+
vi.mocked(fetch).mockResolvedValueOnce({
327+
json: vi.fn().mockResolvedValue({
328+
nodes: [
329+
{
330+
id: 2,
331+
type: 'LoadImage',
332+
widgets_values: ['starter.png', 'image']
333+
}
334+
]
335+
})
336+
} as Partial<Response> as Response)
337+
338+
const result = await loadWorkflowTemplate('template1', 'default', {
339+
input: {
340+
filename: 'first-output.png',
341+
subfolder: 'tour',
342+
type: 'output'
343+
}
344+
})
345+
346+
expect(result).toBe(true)
347+
expect(app.loadGraphData).toHaveBeenCalledWith(
348+
{
349+
nodes: [
350+
{
351+
id: 2,
352+
type: 'LoadImage',
353+
widgets_values: ['tour/first-output.png [output]', 'image']
354+
}
355+
]
356+
},
357+
true,
358+
true,
359+
'template1',
360+
{ openSource: 'template' }
361+
)
362+
})
363+
301364
it('tracks template telemetry on load in cloud builds', async () => {
302365
const { loadWorkflowTemplate } = useTemplateWorkflows()
303366

src/platform/workflow/templates/composables/useTemplateWorkflows.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@ import type {
88
TemplateInfo,
99
WorkflowTemplates
1010
} from '@/platform/workflow/templates/types/template'
11+
import { replaceTemplateImageInput } from '@/platform/workflow/templates/utils/templateWorkflowTransforms'
12+
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
13+
import type { ResultItem } from '@/schemas/apiSchema'
1114
import { api } from '@/scripts/api'
1215
import { app } from '@/scripts/app'
1316
import { useDialogStore } from '@/stores/dialogStore'
1417

18+
interface LoadWorkflowTemplateOptions {
19+
input?: ResultItem
20+
transformWorkflow?: (workflow: ComfyWorkflowJSON) => ComfyWorkflowJSON
21+
}
22+
1523
export function useTemplateWorkflows() {
1624
const { t } = useI18n()
1725
const workflowTemplatesStore = useWorkflowTemplatesStore()
@@ -97,11 +105,15 @@ export function useTemplateWorkflows() {
97105
/**
98106
* Loads a workflow template
99107
*/
100-
const loadWorkflowTemplate = async (id: string, sourceModule: string) => {
108+
const loadWorkflowTemplate = async (
109+
id: string,
110+
sourceModule: string,
111+
options: LoadWorkflowTemplateOptions = {}
112+
) => {
101113
if (!isTemplatesLoaded.value) return false
102114

103115
loadingTemplateId.value = id
104-
let json
116+
let json: ComfyWorkflowJSON
105117

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

141+
if (options.input) {
142+
const template = workflowTemplatesStore.getTemplateByName(id)
143+
if (!template || template.sourceModule !== sourceModule) return false
144+
json = replaceTemplateImageInput(json, template, options.input)
145+
}
146+
147+
if (options.transformWorkflow) json = options.transformWorkflow(json)
148+
129149
const workflowName =
130150
sourceModule === 'default'
131151
? t(`templateWorkflows.template.${id}`, id)
@@ -153,7 +173,10 @@ export function useTemplateWorkflows() {
153173
/**
154174
* Fetches template JSON from the appropriate endpoint
155175
*/
156-
const fetchTemplateJson = async (id: string, sourceModule: string) => {
176+
const fetchTemplateJson = async (
177+
id: string,
178+
sourceModule: string
179+
): Promise<ComfyWorkflowJSON> => {
157180
if (sourceModule === 'default') {
158181
// Default templates provided by frontend are served on this separate endpoint
159182
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())

src/platform/workflow/templates/types/template.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ export interface LogoInfo {
55
position?: string
66
}
77

8+
interface TemplateMediaInfo {
9+
nodeId: string | number
10+
nodeType: string
11+
file: string
12+
mediaType: string
13+
}
14+
15+
interface TemplateIoInfo {
16+
inputs?: TemplateMediaInfo[]
17+
outputs?: TemplateMediaInfo[]
18+
}
19+
820
export interface TemplateInfo {
921
name: string
1022
/**
@@ -67,6 +79,8 @@ export interface TemplateInfo {
6779
* Logo overlays to display on the template thumbnail.
6880
*/
6981
logos?: LogoInfo[]
82+
/** Declared media entry and exit points for continuing from another result. */
83+
io?: TemplateIoInfo
7084
}
7185

7286
export enum TemplateIncludeOnDistributionEnum {
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import type { TemplateInfo } from '../types/template'
4+
import {
5+
replaceTemplateImageInput,
6+
replaceUniqueTemplateWidgetValue
7+
} from './templateWorkflowTransforms'
8+
9+
const template: TemplateInfo = {
10+
name: 'image-template',
11+
description: 'Image template',
12+
mediaType: 'image',
13+
mediaSubtype: 'png',
14+
io: {
15+
inputs: [
16+
{
17+
nodeId: 2,
18+
nodeType: 'LoadImage',
19+
file: 'starter.png',
20+
mediaType: 'image'
21+
}
22+
]
23+
}
24+
}
25+
26+
describe('template workflow transforms', () => {
27+
it('seeds a declared image input with an output asset', () => {
28+
const workflow = {
29+
nodes: [
30+
{
31+
id: 2,
32+
type: 'LoadImage',
33+
widgets_values: ['starter.png', 'image']
34+
}
35+
]
36+
}
37+
38+
const continued = replaceTemplateImageInput(workflow, template, {
39+
filename: 'first-output.png',
40+
subfolder: 'tour',
41+
type: 'output'
42+
})
43+
44+
expect(continued.nodes[0].widgets_values).toEqual([
45+
'tour/first-output.png [output]',
46+
'image'
47+
])
48+
expect(workflow.nodes[0].widgets_values).toEqual(['starter.png', 'image'])
49+
})
50+
51+
it('configures a unique template widget without relying on its node id', () => {
52+
const workflow = {
53+
nodes: [
54+
{
55+
id: 37,
56+
type: 'ImageScaleBy',
57+
widgets_values: ['lanczos', 2]
58+
}
59+
]
60+
}
61+
62+
const configured = replaceUniqueTemplateWidgetValue(
63+
workflow,
64+
'ImageScaleBy',
65+
2,
66+
4
67+
)
68+
69+
expect(configured.nodes[0].widgets_values).toEqual(['lanczos', 4])
70+
})
71+
72+
it('rejects drift between declared input metadata and workflow widgets', () => {
73+
const workflow = {
74+
nodes: [
75+
{
76+
id: 2,
77+
type: 'LoadImage',
78+
widgets_values: ['different.png', 'image']
79+
}
80+
]
81+
}
82+
83+
expect(() =>
84+
replaceTemplateImageInput(workflow, template, {
85+
filename: 'first-output.png'
86+
})
87+
).toThrow('Expected one matching template widget value')
88+
})
89+
})

0 commit comments

Comments
 (0)