Skip to content
Open
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
6 changes: 5 additions & 1 deletion electron/main/model-download-preload.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ test('renderer model actions send only the model node id', async () => {
on: () => {},
removeAllListeners: () => {},
}
const api = createElectronApi(ipc, { setZoomFactor: () => {} })
const api = createElectronApi(
ipc,
{ setZoomFactor: () => {} },
{ getPathForFile: () => '' },
)

await api.model.isDownloaded('pixal3d/generate')
await api.model.hasLocalData('pixal3d/generate')
Expand Down
26 changes: 25 additions & 1 deletion electron/preload/artifact-registry-preload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ test('preload exposes scoped workspace library list/read/open methods', async ()
send: () => undefined,
on: () => undefined,
removeAllListeners: () => undefined,
}, { setZoomFactor: () => undefined })
}, { setZoomFactor: () => undefined }, { getPathForFile: () => '' })

await api.workspace.library.list()
await api.workspace.library.read({
Expand Down Expand Up @@ -43,3 +43,27 @@ test('preload exposes scoped workspace library list/read/open methods', async ()
},
])
})

test('preload resolves dropped file paths through Electron webUtils', () => {
const files: unknown[] = []
const calls: string[] = []
const api = createElectronApi({
invoke: async (channel: string) => {
calls.push(channel)
return null
},
send: () => undefined,
on: () => undefined,
removeAllListeners: () => undefined,
}, { setZoomFactor: () => undefined }, {
getPathForFile: (file) => {
files.push(file)
return 'C:\\images\\input.png'
},
})
const file = {} as Parameters<typeof api.fs.getPathForFile>[0]

assert.equal(api.fs.getPathForFile(file), 'C:\\images\\input.png')
assert.deepEqual(files, [file])
assert.deepEqual(calls, [])
})
6 changes: 5 additions & 1 deletion electron/preload/electron-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export interface WebFrameLike {
setZoomFactor(factor: number): void
}

export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFrameLike) {
export type WebUtilsLike = Pick<typeof import('electron').webUtils, 'getPathForFile'>

export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFrameLike, webUtils: WebUtilsLike) {
return {
// Window controls
window: {
Expand Down Expand Up @@ -73,6 +75,8 @@ export function createElectronApi(ipcRenderer: IpcRendererLike, webFrame: WebFra

// File system dialogs + local file reading
fs: {
getPathForFile: (file: Parameters<WebUtilsLike['getPathForFile']>[0]): string =>
webUtils.getPathForFile(file),
selectImage: (): Promise<string | null> =>
ipcRenderer.invoke('fs:selectImage') as Promise<string | null>,
selectMeshFile: (): Promise<string | null> =>
Expand Down
4 changes: 2 additions & 2 deletions electron/preload/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { contextBridge, ipcRenderer, webFrame } from 'electron'
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'

import { createElectronApi } from './electron-api'

// Expose a typed API to the renderer process via window.electron
contextBridge.exposeInMainWorld('electron', createElectronApi(ipcRenderer, webFrame))
contextBridge.exposeInMainWorld('electron', createElectronApi(ipcRenderer, webFrame, webUtils))
47 changes: 33 additions & 14 deletions src/areas/generate/components/WorkflowPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useWorkflowRunStore } from '@areas/workflows/workflowRunStore'
import { useWaitButton } from '@areas/workflows/useWaitButton'
import { buildAllWorkflowExtensions, getWorkflowExtension } from '@areas/workflows/mockExtensions'
import { validateWorkflowPreflight } from '@areas/workflows/preflight'
import { mimeFromPath } from '@areas/workflows/nodes/imageUtils'
import type { WorkflowExtension } from '@areas/workflows/mockExtensions'
import type { Workflow, WFNode, WFEdge, ParamSchema } from '@shared/types/electron.d'
import { PICKER_LABELS, openParamPicker, resolvePickerIntent } from '@shared/utils/paramPicker'
Expand All @@ -29,6 +30,8 @@ const TYPE_COLOR: Record<string, string> = {
text: '#fbbf24',
}

const SUPPORTED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp'])

// ─── Helpers ──────────────────────────────────────────────────────────────────

function topoSortNodes(nodes: Workflow['nodes'], edges: Workflow['edges']): WFNode[] {
Expand All @@ -54,13 +57,6 @@ function topoSortNodes(nodes: Workflow['nodes'], edges: Workflow['edges']): WFNo
return result
}

function mimeFromPath(p: string): string {
const ext = p.split('.').pop()?.toLowerCase() ?? ''
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg'
if (ext === 'webp') return 'image/webp'
return 'image/png'
}

// ─── Param field ──────────────────────────────────────────────────────────────

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'
Expand Down Expand Up @@ -212,17 +208,40 @@ function ImageParamRow({ nodeId, nodes, onPatch }: { nodeId: string; nodes: Flow
const node = nodes.find((n) => n.id === nodeId)
const data = node?.data as { params: Record<string, unknown> } | undefined
const preview = data?.params.preview as string | undefined
const showToast = useAppStore((state) => state.showToast)
const loadRequest = useRef(0)

const applyImagePath = useCallback(async (path: string | null) => {
const request = ++loadRequest.current
if (!path) return
try {
const base64 = await window.electron.fs.readFileBase64(path)
if (request !== loadRequest.current) return
const src = `data:${mimeFromPath(path)};base64,${base64}`
onPatch(nodeId, { params: { ...(data?.params ?? {}), filePath: path, preview: src } })
} catch {
if (request === loadRequest.current) showToast('Unable to load the selected image')
}
}, [nodeId, data?.params, onPatch, showToast])

const browse = useCallback(async () => {
const p = await window.electron.fs.selectImage()
if (!p) return
const base64 = await window.electron.fs.readFileBase64(p)
const src = `data:${mimeFromPath(p)};base64,${base64}`
onPatch(nodeId, { params: { ...(data?.params ?? {}), filePath: p, preview: src } })
}, [nodeId, data?.params, onPatch])
await applyImagePath(await window.electron.fs.selectImage())
}, [applyImagePath])

return (
<div className="flex flex-col gap-1.5">
<div
className="flex flex-col gap-1.5"
onDragOver={(event) => {
event.preventDefault()
event.dataTransfer.dropEffect = 'copy'
}}
onDrop={(event) => {
event.preventDefault()
const file = event.dataTransfer.files[0]
if (!file || !SUPPORTED_IMAGE_TYPES.has(file.type)) return
void applyImagePath(window.electron.fs.getPathForFile(file))
}}
>
<div className="flex items-center gap-1.5">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="#38bdf8" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
Expand Down
1 change: 1 addition & 0 deletions src/shared/types/electron.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ declare global {
offLog: () => void
}
fs: {
getPathForFile: (file: File) => string
selectImage: () => Promise<string | null>
selectMeshFile: () => Promise<string | null>
saveModel: (defaultName: string) => Promise<string | null>
Expand Down