Skip to content

Commit 7b9415c

Browse files
committed
feat: upload node input handle, segment-anything invert mask, fix API params defaults
- Add media input port to upload node so it can receive upstream free-tool output - Upload node now uploads local-asset:// files to CDN before passing URL downstream - ConnectedInputControl only shows preview when upstream has actual execution results - Add invertMask bool param to segment-anything node - Fix AI task buildApiParams to include schema defaults and enum[0] for untouched params - Hide ML model download hint after first successful run - i18n updates for all 18 locales
1 parent 1b19a1f commit 7b9415c

39 files changed

Lines changed: 1017 additions & 135 deletions

electron.vite.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ export default defineConfig({
5555
}
5656
},
5757
optimizeDeps: {
58+
include: [
59+
'onnxruntime-web',
60+
'upscaler',
61+
'@huggingface/transformers',
62+
'@ffmpeg/ffmpeg',
63+
'@ffmpeg/util'
64+
],
5865
exclude: ['@google/model-viewer']
5966
},
6067
server: {

electron/workflow/db/workflow.repo.ts

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,47 @@ import type { Workflow, GraphDefinition } from '../../../src/workflow/types/work
88

99
function getFileStorage() { return getFileStorageInstance() }
1010

11+
/**
12+
* Ensure a workflow name is unique across all workflows.
13+
* If a collision is found (excluding the workflow with `excludeId`),
14+
* appends (2), (3), etc. until unique.
15+
*/
16+
function ensureUniqueName(db: ReturnType<typeof getDatabase>, name: string, excludeId: string | null): string {
17+
const trimmed = name.trim() || 'Untitled Workflow'
18+
const existingNames = new Set<string>()
19+
const result = excludeId
20+
? db.exec('SELECT name FROM workflows WHERE id != ?', [excludeId])
21+
: db.exec('SELECT name FROM workflows')
22+
if (result.length > 0) {
23+
for (const row of result[0].values) {
24+
existingNames.add(row[0] as string)
25+
}
26+
}
27+
28+
if (!existingNames.has(trimmed)) return trimmed
29+
30+
let counter = 2
31+
while (existingNames.has(`${trimmed} (${counter})`)) counter++
32+
return `${trimmed} (${counter})`
33+
}
34+
1135
export function createWorkflow(name: string): Workflow {
1236
const db = getDatabase()
1337
const id = uuid()
1438
const now = new Date().toISOString()
1539
const graphDef: GraphDefinition = { nodes: [], edges: [] }
40+
41+
// Ensure unique name — append (2), (3), etc. if needed
42+
const finalName = ensureUniqueName(db, name, null)
43+
1644
db.run(
1745
`INSERT INTO workflows (id, name, created_at, updated_at, graph_definition, status) VALUES (?, ?, ?, ?, ?, ?)`,
18-
[id, name, now, now, JSON.stringify(graphDef), 'draft']
46+
[id, finalName, now, now, JSON.stringify(graphDef), 'draft']
1947
)
2048
persistDatabase()
2149
// Also create the workflow directory and initial snapshot on disk
22-
getFileStorage().saveWorkflowSnapshot(id, name, graphDef)
23-
return { id, name, createdAt: now, updatedAt: now, graphDefinition: graphDef, status: 'draft' }
50+
getFileStorage().saveWorkflowSnapshot(id, finalName, graphDef)
51+
return { id, name: finalName, createdAt: now, updatedAt: now, graphDefinition: graphDef, status: 'draft' }
2452
}
2553

2654
export function getWorkflowById(id: string): Workflow | null {
@@ -54,12 +82,20 @@ export function listWorkflows(): Array<{ id: string; name: string; createdAt: st
5482
export function updateWorkflow(id: string, name: string, graphDefinition: GraphDefinition, status?: Workflow['status']): void {
5583
const db = getDatabase()
5684
const now = new Date().toISOString()
85+
86+
// Get old name to detect rename
87+
const existing = getWorkflowById(id)
88+
const oldName = existing?.name ?? name
89+
90+
// Ensure unique name if it changed
91+
const finalName = (name !== oldName) ? ensureUniqueName(db, name, id) : name
92+
5793
if (status) {
5894
db.run('UPDATE workflows SET name = ?, graph_definition = ?, updated_at = ?, status = ? WHERE id = ?',
59-
[name, JSON.stringify(graphDefinition), now, status, id])
95+
[finalName, JSON.stringify(graphDefinition), now, status, id])
6096
} else {
6197
db.run('UPDATE workflows SET name = ?, graph_definition = ?, updated_at = ? WHERE id = ?',
62-
[name, JSON.stringify(graphDefinition), now, id])
98+
[finalName, JSON.stringify(graphDefinition), now, id])
6399
}
64100
// Preserve existing currentOutputId for nodes that already have execution results
65101
const existingOutputIds = new Map<string, string | null>()
@@ -100,19 +136,36 @@ export function updateWorkflow(id: string, name: string, graphDefinition: GraphD
100136
db.run('PRAGMA foreign_keys = ON')
101137
}
102138
persistDatabase()
103-
getFileStorage().saveWorkflowSnapshot(id, name, graphDefinition)
139+
140+
// Rename directory on disk if name changed
141+
if (finalName !== oldName) {
142+
getFileStorage().renameWorkflowDir(id, oldName, finalName)
143+
}
144+
getFileStorage().saveWorkflowSnapshot(id, finalName, graphDefinition)
104145
}
105146

106147
export function renameWorkflow(id: string, newName: string): void {
107148
const db = getDatabase()
108149
const now = new Date().toISOString()
109-
db.run('UPDATE workflows SET name = ?, updated_at = ? WHERE id = ?', [newName, now, id])
110-
persistDatabase()
111-
// Update file storage name mapping and snapshot
150+
151+
// Get old name before rename (needed for directory rename)
112152
const existing = getWorkflowById(id)
113-
if (existing) {
114-
getFileStorage().saveWorkflowSnapshot(id, newName, existing.graphDefinition)
115-
}
153+
if (!existing) return
154+
155+
const oldName = existing.name
156+
157+
// Ensure unique name — append (2), (3), etc. if collides with another workflow
158+
const finalName = ensureUniqueName(db, newName, id)
159+
160+
db.run('UPDATE workflows SET name = ?, updated_at = ? WHERE id = ?', [finalName, now, id])
161+
persistDatabase()
162+
163+
// Rename the data directory on disk (handles collision cleanup)
164+
const fs = getFileStorage()
165+
fs.renameWorkflowDir(id, oldName, finalName)
166+
167+
// Update the snapshot file with the new name
168+
fs.saveWorkflowSnapshot(id, finalName, existing.graphDefinition)
116169
}
117170

118171
export function deleteWorkflow(id: string): void {

electron/workflow/ipc/free-tool.ipc.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,22 @@ export function registerFreeToolIpc(): void {
8181
pending.delete(requestId)
8282
entry.reject(new Error(error))
8383
})
84+
85+
// Detect renderer reload/navigation — fail all pending requests immediately
86+
// so the user sees an error right away instead of waiting for timeout
87+
const onWebContentsCreated = (_event: Electron.Event, webContents: Electron.WebContents) => {
88+
webContents.on('did-start-navigation', () => {
89+
if (pending.size > 0) {
90+
console.warn(`[FreeTool] Renderer reloading — failing ${pending.size} pending request(s)`)
91+
for (const [reqId, entry] of pending) {
92+
pending.delete(reqId)
93+
entry.reject(new Error('Renderer reloaded during execution. Please retry.'))
94+
}
95+
}
96+
})
97+
}
98+
const { app } = require('electron')
99+
app.on('web-contents-created', onWebContentsCreated)
84100
}
85101

86102
/**
@@ -91,7 +107,9 @@ export function executeFreeToolInRenderer(req: Omit<FreeToolExecuteRequest, 'req
91107
const requestId = uuid()
92108
const startTime = Date.now()
93109

94-
const timeoutMs = req.nodeType === 'free-tool/video-enhancer' ? 600_000 : 120_000
110+
const timeoutMs = (req.nodeType === 'free-tool/video-enhancer') ? 600_000
111+
: (req.nodeType === 'free-tool/face-swapper' || req.nodeType === 'free-tool/image-eraser' || req.nodeType === 'free-tool/face-enhancer') ? 300_000
112+
: 120_000
95113
const timeout = setTimeout(() => {
96114
const entry = pending.get(requestId)
97115
if (entry) {

electron/workflow/ipc/storage.ipc.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ipcMain, dialog, shell } from 'electron'
55
import { getFileStorageInstance } from '../utils/file-storage'
66
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs'
77
import { v4 as uuid } from 'uuid'
8-
import { createWorkflow, updateWorkflow, listWorkflows } from '../db/workflow.repo'
8+
import { createWorkflow, updateWorkflow } from '../db/workflow.repo'
99
import { getModelById } from '../services/model-list'
1010

1111
function getStorage() { return getFileStorageInstance() }
@@ -90,14 +90,8 @@ export function registerStorageIpc(): void {
9090
const name = data.name ?? 'Imported Workflow'
9191
if (!rawGraphDef || !Array.isArray(rawGraphDef.nodes)) throw new Error('Invalid workflow file')
9292

93-
// Check for name conflicts
93+
// createWorkflow now auto-deduplicates names, so no need for manual conflict check
9494
const importName = name + ' (imported)'
95-
const existing = listWorkflows()
96-
if (existing.some(w => w.name === importName || w.name === name)) {
97-
throw new Error(`A workflow named "${name}" already exists. Please rename the "name" field in the JSON file before importing.`)
98-
}
99-
100-
// Generate new UUIDs for all nodes/edges to avoid conflicts on re-import
10195
const wf = createWorkflow(importName)
10296
const idMap = new Map<string, string>() // old ID → new UUID
10397
for (const n of rawGraphDef.nodes) {

electron/workflow/ipc/workflow.ipc.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,11 @@ export function registerWorkflowIpc(): void {
2626
return workflowRepo.listWorkflows()
2727
})
2828

29-
ipcMain.handle('workflow:rename', async (_event, args: { id: string; name: string }): Promise<void> => {
29+
ipcMain.handle('workflow:rename', async (_event, args: { id: string; name: string }): Promise<{ finalName: string }> => {
3030
workflowRepo.renameWorkflow(args.id, args.name)
31+
// Return the actual name (may have been deduplicated)
32+
const wf = workflowRepo.getWorkflowById(args.id)
33+
return { finalName: wf?.name ?? args.name }
3134
})
3235

3336
ipcMain.handle('workflow:delete', async (_event, args: { id: string }): Promise<void> => {

electron/workflow/nodes/ai-task/run.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,22 @@ export class AITaskHandler extends BaseNodeHandler {
8181
const params: Record<string, unknown> = {}
8282
// Internal keys to skip
8383
const skipKeys = new Set(['modelId', '__meta', '__locks', '__nodeWidth', '__nodeHeight'])
84+
85+
// First, fill in schema defaults so params that the user never touched
86+
// (but are visible in the UI with their default value) are still sent.
87+
const meta = ctx.params.__meta as Record<string, unknown> | undefined
88+
const schema = (meta?.modelInputSchema ?? []) as Array<{ name: string; default?: unknown; enum?: string[] }>
89+
for (const s of schema) {
90+
if (skipKeys.has(s.name) || s.name.startsWith('__')) continue
91+
if (s.default !== undefined && s.default !== null && s.default !== '') {
92+
params[s.name] = s.default
93+
} else if (s.enum && s.enum.length > 0) {
94+
// Select/enum fields show the first option by default in the UI
95+
params[s.name] = s.enum[0]
96+
}
97+
}
98+
99+
// Then overlay with actual user-set params (these take priority)
84100
for (const [key, value] of Object.entries(ctx.params)) {
85101
if (skipKeys.has(key) || key.startsWith('__')) continue
86102
if (value !== undefined && value !== null && value !== '') params[key] = value

electron/workflow/nodes/free-tool/segment-anything/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ export const segmentAnythingDef: NodeTypeDefinition = {
99
icon: '🖱️',
1010
inputs: [{ key: 'input', label: 'Image', dataType: 'image', required: true }],
1111
outputs: [{ key: 'output', label: 'Mask', dataType: 'image', required: true }],
12-
params: []
12+
params: [
13+
{ key: 'invertMask', label: 'Invert Mask', type: 'boolean', default: false }
14+
]
1315
}
1416

1517
export class SegmentAnythingHandler extends BaseNodeHandler {
@@ -42,7 +44,8 @@ export class SegmentAnythingHandler extends BaseNodeHandler {
4244
pointX: ctx.params.pointX ?? 0.5,
4345
pointY: ctx.params.pointY ?? 0.5,
4446
__segmentPoints: ctx.params.__segmentPoints,
45-
__previewMask: ctx.params.__previewMask
47+
__previewMask: ctx.params.__previewMask,
48+
invertMask: ctx.params.invertMask ?? false
4649
}
4750
})
4851
ctx.onProgress(100, 'Segmentation completed.')

electron/workflow/nodes/input/media-upload.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,25 @@
66
* The actual upload happens in the renderer (CustomNode) and the
77
* resulting URL is stored in params.uploadedUrl.
88
*
9-
* When executed, this node simply passes through the uploadedUrl.
9+
* When executed:
10+
* - If a CDN URL is already available (manual upload), pass it through.
11+
* - If input is a local-asset:// path (from upstream free-tool), read the
12+
* file from disk and upload it to CDN first.
1013
*/
1114
import { BaseNodeHandler, type NodeExecutionContext, type NodeExecutionResult } from '../base'
1215
import type { NodeTypeDefinition } from '../../../../src/workflow/types/node-defs'
16+
import { getWaveSpeedClient } from '../../services/service-locator'
17+
import { existsSync, readFileSync } from 'fs'
18+
import { basename } from 'path'
1319

1420
export const mediaUploadDef: NodeTypeDefinition = {
1521
type: 'input/media-upload',
1622
category: 'input',
1723
label: 'Upload',
1824
icon: '📁',
19-
inputs: [],
25+
inputs: [
26+
{ key: 'media', label: 'Media', dataType: 'url', required: false }
27+
],
2028
outputs: [
2129
{ key: 'output', label: 'URL', dataType: 'url', required: true }
2230
],
@@ -33,10 +41,33 @@ export class MediaUploadHandler extends BaseNodeHandler {
3341

3442
async execute(ctx: NodeExecutionContext): Promise<NodeExecutionResult> {
3543
const start = Date.now()
36-
const url = String(ctx.params.uploadedUrl ?? '')
44+
// Prefer connected input over manually uploaded URL
45+
let url = String(ctx.inputs.media ?? ctx.params.uploadedUrl ?? '')
3746

3847
if (!url) {
39-
return { status: 'error', outputs: {}, durationMs: Date.now() - start, cost: 0, error: 'No file uploaded. Please upload a file first.' }
48+
return { status: 'error', outputs: {}, durationMs: Date.now() - start, cost: 0, error: 'No file uploaded or connected. Please upload a file or connect a media source.' }
49+
}
50+
51+
// If the URL is a local-asset:// path (from upstream free-tool nodes),
52+
// read the file from disk and upload it to CDN so downstream API nodes
53+
// receive a proper HTTP URL.
54+
if (/^local-asset:\/\//i.test(url)) {
55+
try {
56+
ctx.onProgress(10, 'Uploading local file to CDN...')
57+
const localPath = decodeURIComponent(url.replace(/^local-asset:\/\//i, ''))
58+
if (!existsSync(localPath)) {
59+
return { status: 'error', outputs: {}, durationMs: Date.now() - start, cost: 0, error: `Local file not found: ${localPath}` }
60+
}
61+
const buffer = readFileSync(localPath)
62+
const filename = basename(localPath)
63+
const blob = new Blob([buffer])
64+
const file = new File([blob], filename)
65+
const client = getWaveSpeedClient()
66+
url = await client.uploadFile(file, filename)
67+
ctx.onProgress(90, 'Upload complete')
68+
} catch (error) {
69+
return { status: 'error', outputs: {}, durationMs: Date.now() - start, cost: 0, error: `Failed to upload local file: ${error instanceof Error ? error.message : String(error)}` }
70+
}
4071
}
4172

4273
return {

electron/workflow/utils/file-storage.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,43 @@ export class FileStorageService {
9090
return this.nameMap.get(workflowId) ?? workflowId
9191
}
9292

93+
/**
94+
* Rename a workflow's data directory on disk.
95+
* If the old directory exists, rename it to the new sanitized name.
96+
* If the target directory already exists (name collision from another workflow),
97+
* remove the target first so the rename can proceed.
98+
* Returns true if rename succeeded, false if no old dir existed.
99+
*/
100+
renameWorkflowDir(workflowId: string, oldName: string, newName: string): boolean {
101+
const oldSanitized = sanitizeName(oldName)
102+
const newSanitized = sanitizeName(newName)
103+
104+
// No-op if sanitized names are the same
105+
if (oldSanitized === newSanitized) {
106+
this.registerWorkflowName(workflowId, newName)
107+
return true
108+
}
109+
110+
const oldDir = path.join(this.rootPath, oldSanitized)
111+
const newDir = path.join(this.rootPath, newSanitized)
112+
113+
if (!fs.existsSync(oldDir)) {
114+
// Old dir doesn't exist — just update the name mapping
115+
this.registerWorkflowName(workflowId, newName)
116+
return false
117+
}
118+
119+
// If target directory already exists (orphaned from a deleted/renamed workflow),
120+
// remove it so we can rename cleanly
121+
if (fs.existsSync(newDir)) {
122+
fs.rmSync(newDir, { recursive: true, force: true })
123+
}
124+
125+
fs.renameSync(oldDir, newDir)
126+
this.registerWorkflowName(workflowId, newName)
127+
return true
128+
}
129+
93130
/* ─── Path helpers ──────────────────────────────────────────── */
94131

95132
getWorkflowDir(workflowId: string): string {

src/components/layout/Sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ export function Sidebar({ collapsed, onToggle, lastFreeToolsPage, isMobileOpen,
195195
{group.items.map((item) => {
196196
const active = isActive(item)
197197
const showTooltip = collapsed && !isMobileOpen && tooltipReady
198-
const isNewFeature = item.href === '/workflow'
198+
const isNewFeature = false
199199
return (
200200
<Tooltip key={item.href} delayDuration={0} open={showTooltip ? undefined : false}>
201201
<TooltipTrigger asChild>

0 commit comments

Comments
 (0)