Skip to content
Merged
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
38 changes: 28 additions & 10 deletions src/areas/generate/components/GenerationOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,24 +84,42 @@ function FloatParam({ schema, value, onChange }: { schema: ParamSchema; value: a
)
}

function IntInput({ value, onChange, placeholder, className }: { value: number; onChange: (v: number) => void; placeholder?: string; className: string }) {
const [text, setText] = useState(String(value))
const prevValue = useRef(value)
if (prevValue.current !== value && parseInt(text, 10) !== value) {
prevValue.current = value
setText(String(value))
}
return (
<input
type="text"
inputMode="numeric"
value={text}
placeholder={placeholder}
onChange={(e) => {
const raw = e.target.value
if (raw !== '' && raw !== '-' && !/^-?\d+$/.test(raw)) return
setText(raw)
const n = parseInt(raw, 10)
if (!isNaN(n)) { prevValue.current = n; onChange(n) }
}}
className={className}
/>
)
}

function IntParam({ schema, value, onChange }: { schema: ParamSchema; value: any; onChange: (v: any) => void }): JSX.Element {
const isSeed = schema.id === 'seed'
return (
<div className="flex flex-col gap-1.5">
<FieldLabel label={schema.label} tooltip={schema.tooltip} />
<div className="flex items-center gap-2">
<input
type="number"
lang="en"
min={isSeed ? -1 : schema.min}
max={schema.max}
<IntInput
value={value}
onChange={onChange}
placeholder={isSeed ? '-1 = random' : undefined}
onChange={(e) => {
const v = parseInt(e.target.value)
onChange(isNaN(v) ? schema.default : v)
}}
className="w-full px-3 py-1.5 text-xs rounded-lg bg-zinc-900 border border-zinc-700/60 text-zinc-200 focus:outline-none focus:border-zinc-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
className="w-full px-3 py-1.5 text-xs rounded-lg bg-zinc-900 border border-zinc-700/60 text-zinc-200 focus:outline-none focus:border-zinc-500"
/>
{isSeed && (
<button
Expand Down
109 changes: 61 additions & 48 deletions src/areas/generate/components/WorkflowPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ReactFlowProvider,
useNodesState, useEdgesState, useReactFlow,
Expand All @@ -9,7 +9,7 @@ import { useWorkflowsStore } from '@shared/stores/workflowsStore'
import { useAppStore } from '@shared/stores/appStore'
import { useExtensionsStore } from '@shared/stores/extensionsStore'
import { useNavStore } from '@shared/stores/navStore'
import { useWorkflowRunner } from '@areas/workflows/useWorkflowRunner'
import { useWorkflowRunStore } from '@areas/workflows/workflowRunStore'
import { buildAllWorkflowExtensions, getWorkflowExtension } from '@areas/workflows/mockExtensions'
import type { WorkflowExtension } from '@areas/workflows/mockExtensions'
import type { Workflow, WFNode, WFEdge, ParamSchema } from '@shared/types/electron.d'
Expand Down Expand Up @@ -58,6 +58,54 @@ function mimeFromPath(p: string): string {

const inputCls = 'w-full bg-zinc-800 border border-zinc-700/80 rounded-md px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60'

function IntInput({ value, onChange, className }: { value: number; onChange: (v: number) => void; className: string }) {
const [text, setText] = useState(String(value))
const prevValue = useRef(value)
if (prevValue.current !== value && parseInt(text, 10) !== value) {
prevValue.current = value
setText(String(value))
}
return (
<input
type="text"
inputMode="numeric"
value={text}
onChange={(e) => {
const raw = e.target.value
if (raw !== '' && raw !== '-' && !/^-?\d+$/.test(raw)) return
setText(raw)
const n = parseInt(raw, 10)
if (!isNaN(n)) { prevValue.current = n; onChange(n) }
}}
className={className}
/>
)
}

function FloatInput({ value, onChange, className }: { value: number; onChange: (v: number) => void; className: string }) {
const [text, setText] = useState(String(value))
const prevValue = useRef(value)
if (prevValue.current !== value && parseFloat(text.replace(',', '.')) !== value) {
prevValue.current = value
setText(String(value))
}
return (
<input
type="text"
inputMode="decimal"
value={text}
onChange={(e) => {
const raw = e.target.value.replace(',', '.')
if (raw !== '' && raw !== '-' && raw !== '.' && !/^-?\d*\.?\d*$/.test(raw)) return
setText(e.target.value)
const num = parseFloat(raw)
if (!isNaN(num)) { prevValue.current = num; onChange(num) }
}}
className={className}
/>
)
}

function ParamField({ param, value, onChange }: {
param: ParamSchema
value: number | string
Expand Down Expand Up @@ -88,12 +136,11 @@ function ParamField({ param, value, onChange }: {
</div>
)
}
return (
<input type="number" lang="en" value={value as number} min={param.min} max={param.max}
step={param.step ?? (param.type === 'float' ? 0.1 : 1)}
onChange={(e) => onChange(param.type === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value, 10))}
className={inputCls} />
)
if (param.type === 'float') {
return <FloatInput value={value as number} onChange={(v) => onChange(v)} className={inputCls} />
}
// int
return <IntInput value={value as number} onChange={(v) => onChange(v)} className={inputCls} />
}

// ─── Workflow dropdown ────────────────────────────────────────────────────────
Expand Down Expand Up @@ -362,8 +409,8 @@ function EmbeddedCanvas({ workflow, allExtensions }: {
))
}, [setNodes])

const { setCurrentJob, updateCurrentJob, selectedImagePath, selectedImageData } = useAppStore()
const { runState, run, cancel } = useWorkflowRunner(allExtensions)
const { setCurrentJob } = useAppStore()
const { runState, run, cancel } = useWorkflowRunStore()
const isRunning = runState.status === 'running'

// Update AddToScene node when run completes
Expand All @@ -373,21 +420,6 @@ function EmbeddedCanvas({ workflow, allExtensions }: {
if (out) updateNodeData(out.id, { params: { outputUrl: runState.outputUrl } })
}, [runState.status, runState.outputUrl])

// Sync runState → currentJob
useEffect(() => {
if (runState.status === 'running') {
const total = runState.blockTotal
const overall = total > 0
? Math.round((runState.blockIndex / total) * 100 + runState.blockProgress / total)
: runState.blockProgress
updateCurrentJob({ status: 'generating', progress: overall, step: runState.blockStep })
} else if (runState.status === 'done') {
updateCurrentJob({ status: 'done', progress: 100, outputUrl: runState.outputUrl })
} else if (runState.status === 'error') {
updateCurrentJob({ status: 'error', error: runState.error })
}
}, [runState])

// Type mismatch detection
const typeMismatch = useMemo(() => {
const sorted = topoSortNodes(workflow.nodes, workflow.edges)
Expand Down Expand Up @@ -418,28 +450,9 @@ function EmbeddedCanvas({ workflow, allExtensions }: {
)

const handleGenerate = useCallback(() => {
const imageNode = nodes.find((n) => n.type === 'imageNode')
const imagePath = (imageNode?.data?.params?.filePath as string | undefined) ?? selectedImagePath ?? ''
const imageData = selectedImageData ?? undefined

// Capture the current mesh URL *before* setCurrentJob overwrites it
const currentMeshUrl = useAppStore.getState().currentJob?.outputUrl

setCurrentJob({
id: crypto.randomUUID(),
imageFile: imagePath,
status: 'uploading',
progress: 0,
createdAt: Date.now(),
})

run(
{ ...workflow, nodes: nodes as WFNode[], edges: edges as WFEdge[] },
imagePath,
imageData,
currentMeshUrl,
)
}, [nodes, edges, workflow, selectedImagePath, selectedImageData, allExtensions, setCurrentJob, run])
const wf: Workflow = { ...workflow, nodes: nodes as WFNode[], edges: edges as WFEdge[] }
run(wf, allExtensions)
}, [nodes, edges, workflow, allExtensions, run])

return (
<div className="flex flex-col flex-1 min-h-0">
Expand Down Expand Up @@ -495,7 +508,7 @@ function EmbeddedCanvas({ workflow, allExtensions }: {
</div>
)}
{isRunning ? (
<button onClick={() => { cancel(); setCurrentJob(null) }}
<button onClick={() => cancel()}
className="w-full py-2.5 rounded-lg text-sm font-semibold bg-red-600 hover:bg-red-700 text-white transition-colors">
Stop
</button>
Expand Down
60 changes: 54 additions & 6 deletions src/areas/workflows/nodes/ExtensionNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,55 @@ const TAG_CLS: Record<string, string> = {

const inputCls = 'w-full bg-zinc-800 border border-zinc-700 rounded-lg px-2 py-1 text-[11px] text-zinc-200 focus:outline-none focus:border-accent/60'

function IntInput({ value, onChange, className }: { value: number; onChange: (v: number) => void; className: string }) {
const [text, setText] = useState(String(value))
const prevValue = useRef(value)
if (prevValue.current !== value && parseInt(text, 10) !== value) {
prevValue.current = value
setText(String(value))
}
return (
<input
type="text"
inputMode="numeric"
value={text}
onChange={(e) => {
const raw = e.target.value
if (raw !== '' && raw !== '-' && !/^-?\d+$/.test(raw)) return
setText(raw)
const n = parseInt(raw, 10)
if (!isNaN(n)) { prevValue.current = n; onChange(n) }
}}
className={className}
/>
)
}

function FloatInput({ value, onChange, className }: { value: number; onChange: (v: number) => void; className: string }) {
const [text, setText] = useState(String(value))
// Sync when external value changes (e.g. reset)
const prevValue = useRef(value)
if (prevValue.current !== value && parseFloat(text.replace(',', '.')) !== value) {
prevValue.current = value
setText(String(value))
}
return (
<input
type="text"
inputMode="decimal"
value={text}
onChange={(e) => {
const raw = e.target.value.replace(',', '.')
if (raw !== '' && raw !== '-' && raw !== '.' && !/^-?\d*\.?\d*$/.test(raw)) return
setText(e.target.value)
const num = parseFloat(raw)
if (!isNaN(num)) { prevValue.current = num; onChange(num) }
}}
className={className}
/>
)
}

function ParamControl({ param, value, onChange }: {
param: ParamSchema
value: number | string
Expand Down Expand Up @@ -58,12 +107,11 @@ function ParamControl({ param, value, onChange }: {
</div>
)
}
return (
<input type="number" lang="en" value={value as number} min={param.min} max={param.max}
step={param.step ?? (param.type === 'float' ? 0.1 : 1)}
onChange={(e) => onChange(param.type === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value, 10))}
className={inputCls} />
)
if (param.type === 'float') {
return <FloatInput value={value as number} onChange={(v) => onChange(v)} className={inputCls} />
}
// int
return <IntInput value={value as number} onChange={(v) => onChange(v)} className={inputCls} />
}

// ─── ExtensionNode ────────────────────────────────────────────────────────────
Expand Down
12 changes: 7 additions & 5 deletions src/areas/workflows/workflowRunStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,19 @@ export const useWorkflowRunStore = create<WorkflowRunStore>((set) => ({
const ordered = topoSort(workflow.nodes, workflow.edges)
const execNodes = ordered.filter((n) => n.type === 'extensionNode' && n.data.enabled)

// Capture before setCurrentJob overwrites currentJob
const selectedImagePath = appState.selectedImagePath ?? ''
const selectedImageData = appState.selectedImageData ?? undefined
const currentMeshUrl = appState.currentJob?.outputUrl

set({
activeWorkflowId: workflow.id,
runState: { status: 'running', blockIndex: 0, blockTotal: execNodes.length, blockProgress: 0, blockStep: 'Starting…' },
})

appState.setCurrentJob({
id: crypto.randomUUID(),
imageFile: '',
imageFile: selectedImagePath,
status: 'generating',
progress: 0,
createdAt: Date.now(),
Expand All @@ -94,10 +99,7 @@ export const useWorkflowRunStore = create<WorkflowRunStore>((set) => ({
const settings = await window.electron.settings.get()
const workspaceDir = settings.workspaceDir.replace(/\\/g, '/')

const nodeOutputs = new Map<string, { filePath?: string; text?: string }>()
const selectedImagePath = useAppStore.getState().selectedImagePath ?? ''
const selectedImageData = useAppStore.getState().selectedImageData ?? undefined
const currentMeshUrl = useAppStore.getState().currentJob?.outputUrl
const nodeOutputs = new Map<string, { filePath?: string; text?: string }>()

// Pre-populate source nodes
for (const node of ordered) {
Expand Down