gerbil/src/main/modules/comfyui.ts

599 lines
16 KiB
TypeScript

import { spawn } from 'child_process';
import { dirname, join } from 'path';
import {
access,
stat,
mkdir,
unlink,
copyFile,
readFile,
writeFile,
rm,
} from 'fs/promises';
import { platform, on } from 'process';
import type { ChildProcess } from 'child_process';
import yauzl from 'yauzl';
import { createWriteStream } from 'fs';
import { app } from 'electron';
import { logError, safeExecute, tryExecute } from '@/utils/node/logging';
import { sendKoboldOutput } from './window';
import { getInstallDir } from './config';
import { COMFYUI, SERVER_READY_SIGNALS, GITHUB_API } from '@/constants';
import { terminateProcess } from '@/utils/node/process';
import { parseKoboldConfig } from '@/utils/node/kobold';
import { ensureDir } from '@/utils/node/fs';
import { detectGPU } from './hardware';
import { getUvEnvironment } from './dependencies';
interface ComfyUIVersionInfo {
sha: string;
date: string;
}
async function getLatestComfyUIVersion() {
return safeExecute(async () => {
const response = await fetch(GITHUB_API.COMFYUI_LATEST_COMMIT_URL);
if (!response.ok) {
throw new Error(
`Failed to fetch ComfyUI version: ${response.statusText}`
);
}
const data = await response.json();
return {
sha: data.sha,
date: data.commit.committer.date,
};
}, 'Failed to fetch latest ComfyUI version');
}
async function getCurrentComfyUIVersion(workspaceDir: string) {
try {
const versionFile = join(workspaceDir, '.version.json');
const content = await readFile(versionFile, 'utf-8');
return JSON.parse(content);
} catch {
return null;
}
}
async function saveComfyUIVersion(
workspaceDir: string,
version: ComfyUIVersionInfo
) {
await tryExecute(async () => {
const versionFile = join(workspaceDir, '.version.json');
await writeFile(versionFile, JSON.stringify(version, null, 2));
}, 'Failed to save ComfyUI version info');
}
async function shouldUpdateComfyUI(workspaceDir: string) {
const current = await getCurrentComfyUIVersion(workspaceDir);
const latest = await getLatestComfyUIVersion();
if (!current || !latest) {
return false;
}
return current.sha !== latest.sha;
}
let comfyUIProcess: ChildProcess | null = null;
function getPythonPath(workspaceDir: string) {
const isWindows = platform === 'win32';
const pythonExecutable = isWindows ? 'python.exe' : 'python';
const scriptsDir = isWindows ? 'Scripts' : 'bin';
return join(workspaceDir, '.venv', scriptsDir, pythonExecutable);
}
on('SIGINT', () => {
void stopFrontend();
});
on('SIGTERM', () => {
void stopFrontend();
});
async function shouldForceCPUMode() {
try {
const gpuInfo = await detectGPU();
const { hasAMD, hasNVIDIA } = gpuInfo;
const isWindows = platform === 'win32';
return (hasAMD && isWindows) || (!hasAMD && !hasNVIDIA);
} catch {
return true;
}
}
async function getPyTorchInstallArgs(pythonPath: string) {
const args = [
'pip',
'install',
'--python',
pythonPath,
'torch',
'torchvision',
'torchaudio',
];
try {
const gpuInfo = await detectGPU();
const { hasAMD, hasNVIDIA } = gpuInfo;
const isWindows = platform === 'win32';
if (hasAMD && !isWindows) {
sendKoboldOutput(
'AMD GPU detected, installing PyTorch with ROCm support...'
);
args.push('--index-url', 'https://download.pytorch.org/whl/rocm6.4');
} else if (hasNVIDIA) {
sendKoboldOutput(
'NVIDIA GPU detected, installing PyTorch with CUDA support...'
);
args.push('--extra-index-url', 'https://download.pytorch.org/whl/cu121');
} else {
if (hasAMD && isWindows) {
sendKoboldOutput(
'AMD GPU detected on Windows, installing CPU PyTorch (ROCm not available on Windows)...'
);
} else {
sendKoboldOutput(
'No dedicated GPU detected, installing CPU-only PyTorch...'
);
}
args.push('--index-url', 'https://download.pytorch.org/whl/cpu');
}
} catch {
sendKoboldOutput('Could not detect GPU, installing default PyTorch...');
}
return args;
}
async function downloadAndExtractComfyUI(workspaceDir: string) {
sendKoboldOutput('Downloading ComfyUI...');
const response = await fetch(GITHUB_API.COMFYUI_DOWNLOAD_URL);
if (!response.ok) {
throw new Error(`Failed to download ComfyUI: ${response.statusText}`);
}
const zipPath = join(workspaceDir, 'comfyui.zip');
const stream = createWriteStream(zipPath);
if (response.body) {
const reader = response.body.getReader();
const pump = async (): Promise<void> => {
const { done, value } = await reader.read();
if (done) {
stream.end();
return;
}
stream.write(Buffer.from(value));
return pump();
};
await pump();
await new Promise<void>((resolve, reject) => {
stream.on('finish', resolve);
stream.on('error', reject);
});
}
const zipStats = await stat(zipPath);
sendKoboldOutput(
`ComfyUI downloaded (${Math.round(zipStats.size / 1024 / 1024)}MB), extracting...`
);
await new Promise<void>((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
if (err) {
reject(err);
return;
}
zipfile!.readEntry();
zipfile!.on('entry', (entry) => {
const entryPath = entry.fileName;
const destPath = join(workspaceDir, entryPath.replace(/^[^/]+\//, ''));
if (/\/$/.test(entryPath)) {
mkdir(destPath, { recursive: true })
.then(() => zipfile!.readEntry())
.catch(reject);
} else {
zipfile!.openReadStream(entry, (err, readStream) => {
if (err) {
reject(err);
return;
}
mkdir(dirname(destPath), { recursive: true })
.then(() => {
const writeStream = createWriteStream(destPath);
readStream!.pipe(writeStream);
writeStream.on('close', () => zipfile!.readEntry());
writeStream.on('error', reject);
})
.catch(reject);
});
}
});
zipfile!.on('end', () => {
unlink(zipPath)
.then(() => resolve())
.catch(reject);
});
});
});
sendKoboldOutput('ComfyUI extracted successfully');
const latestVersion = await getLatestComfyUIVersion();
if (latestVersion) {
await saveComfyUIVersion(workspaceDir, latestVersion);
}
}
async function installDependencies(
workspaceDir: string,
env: Record<string, string | undefined>
) {
sendKoboldOutput('Installing ComfyUI dependencies...');
const requirementsPath = join(workspaceDir, 'requirements.txt');
const requirementsExists = await access(requirementsPath)
.then(() => true)
.catch(() => false);
if (!requirementsExists) {
throw new Error('requirements.txt not found in ComfyUI directory');
}
const pythonPath = getPythonPath(workspaceDir);
const torchInstallArgs = await getPyTorchInstallArgs(pythonPath);
const torchInstallProcess = spawn('uv', torchInstallArgs, {
stdio: 'pipe',
env,
});
return new Promise<void>((resolve, reject) => {
torchInstallProcess.on('exit', (torchCode: number | null) => {
if (torchCode === 0) {
sendKoboldOutput(
'PyTorch installed successfully, installing remaining dependencies...'
);
const pipInstallProcess = spawn(
'uv',
[
'pip',
'install',
'--python',
getPythonPath(workspaceDir),
'-r',
requirementsPath,
],
{
stdio: 'pipe',
env,
}
);
pipInstallProcess.on('exit', (pipCode: number | null) => {
if (pipCode === 0) {
sendKoboldOutput('ComfyUI dependencies installed successfully');
const verifyProcess = spawn(
getPythonPath(workspaceDir),
[
'-c',
'import torch; print("PyTorch version:", torch.__version__)',
],
{
stdio: 'pipe',
env,
}
);
verifyProcess.on('exit', (verifyCode: number | null) => {
if (verifyCode === 0) {
sendKoboldOutput('ComfyUI installation verified successfully');
resolve();
} else {
reject(
new Error(
`ComfyUI verification failed with code ${verifyCode}`
)
);
}
});
verifyProcess.on('error', (error) => {
reject(error);
});
if (verifyProcess.stdout) {
verifyProcess.stdout.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString('utf8'), true);
});
}
if (verifyProcess.stderr) {
verifyProcess.stderr.on('data', (data: Buffer) => {
sendKoboldOutput(
`Verify Error: ${data.toString('utf8')}`,
true
);
});
}
} else {
reject(
new Error(`Dependency installation failed with code ${pipCode}`)
);
}
});
pipInstallProcess.on('error', (error) => {
reject(error);
});
if (pipInstallProcess.stdout) {
pipInstallProcess.stdout.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString('utf8'), true);
});
}
if (pipInstallProcess.stderr) {
pipInstallProcess.stderr.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString('utf8'), true);
});
}
} else {
reject(new Error(`PyTorch installation failed with code ${torchCode}`));
}
});
torchInstallProcess.on('error', (error) => {
reject(error);
});
if (torchInstallProcess.stdout) {
torchInstallProcess.stdout.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString('utf8'), true);
});
}
if (torchInstallProcess.stderr) {
torchInstallProcess.stderr.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString('utf8'), true);
});
}
});
}
async function installComfyUI(workspaceDir: string) {
const env = await getUvEnvironment();
sendKoboldOutput('Creating virtual environment...');
const installProcess = spawn(
'uv',
['venv', '--python', '3.11', join(workspaceDir, '.venv')],
{
stdio: 'pipe',
env,
}
);
return new Promise<void>((resolve, reject) => {
installProcess.on('exit', async (code) => {
if (code === 0) {
try {
await downloadAndExtractComfyUI(workspaceDir);
await installDependencies(workspaceDir, env);
resolve();
} catch (error) {
reject(error instanceof Error ? error : new Error(String(error)));
}
} else {
reject(
new Error(`Virtual environment creation failed with code ${code}`)
);
}
});
installProcess.on('error', (error) => {
reject(error);
});
if (installProcess.stdout) {
installProcess.stdout.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString('utf8'), true);
});
}
if (installProcess.stderr) {
installProcess.stderr.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString('utf8'), true);
});
}
});
}
async function updateComfyUI(workspaceDir: string) {
const backupDir = join(workspaceDir, 'backup');
await ensureDir(backupDir);
const filesToBackup = ['main.py', 'requirements.txt'];
const backupPromises = filesToBackup.map(async (file) => {
const srcPath = join(workspaceDir, file);
const destPath = join(backupDir, file);
try {
await copyFile(srcPath, destPath);
} catch (error) {
void error;
}
});
await Promise.all(backupPromises);
try {
await downloadAndExtractComfyUI(workspaceDir);
sendKoboldOutput('ComfyUI updated successfully');
await rm(backupDir, { recursive: true, force: true });
} catch (error) {
sendKoboldOutput('Update failed, restoring backup...');
const restorePromises = filesToBackup.map(async (file) => {
const srcPath = join(backupDir, file);
const destPath = join(workspaceDir, file);
try {
await copyFile(srcPath, destPath);
} catch (error) {
void error;
}
});
await Promise.all(restorePromises);
await rm(backupDir, { recursive: true, force: true });
throw error;
}
}
async function ensureComfyUIInstalled() {
const installDir = getInstallDir();
const comfyUIWorkspace = join(installDir, 'comfyui-workspace');
await ensureDir(comfyUIWorkspace);
const comfyUIMainPath = join(comfyUIWorkspace, 'main.py');
const isComfyUIInstalled = await access(comfyUIMainPath)
.then(() => true)
.catch(() => false);
const needsUpdate =
isComfyUIInstalled && (await shouldUpdateComfyUI(comfyUIWorkspace));
if (!isComfyUIInstalled) {
sendKoboldOutput('ComfyUI not found, installing...');
await installComfyUI(comfyUIWorkspace);
} else if (needsUpdate) {
sendKoboldOutput('ComfyUI update available, updating...');
await updateComfyUI(comfyUIWorkspace);
} else {
sendKoboldOutput('ComfyUI found in workspace and up to date');
}
}
async function waitForComfyUIToStart() {
return new Promise<void>((resolve, reject) => {
const checkForOutput = (data: Buffer) => {
const output = data.toString();
if (output.includes(SERVER_READY_SIGNALS.COMFYUI)) {
sendKoboldOutput('ComfyUI is now running!');
resolve();
if (comfyUIProcess?.stdout) {
comfyUIProcess.stdout.removeListener('data', checkForOutput);
}
}
};
if (comfyUIProcess?.stdout) {
comfyUIProcess.stdout.on('data', checkForOutput);
} else {
reject(new Error('ComfyUI process stdout not available'));
}
});
}
export async function startFrontend(args: string[]) {
try {
const config = {
name: 'comfyui',
port: COMFYUI.PORT,
};
const { host: koboldHost, port: koboldPort } = parseKoboldConfig(args);
sendKoboldOutput(`Starting ComfyUI frontend on port ${config.port}...`);
await ensureComfyUIInstalled();
const installDir = getInstallDir();
const comfyUIWorkspace = join(installDir, 'comfyui-workspace');
const pythonPath = getPythonPath(comfyUIWorkspace);
const comfyUIArgs = [
join(comfyUIWorkspace, 'main.py'),
'--port',
config.port.toString(),
'--enable-cors-header',
];
const forceCPU = await shouldForceCPUMode();
if (forceCPU) {
comfyUIArgs.push('--cpu');
sendKoboldOutput(
'Forcing ComfyUI to use CPU mode (no compatible GPU acceleration available)'
);
}
if (koboldHost && koboldPort) {
sendKoboldOutput(
`Connecting to KoboldCpp at ${koboldHost}:${koboldPort}`
);
}
sendKoboldOutput(`Running ComfyUI with Python: ${pythonPath}`);
sendKoboldOutput(`ComfyUI args: ${comfyUIArgs.join(' ')}`);
comfyUIProcess = spawn(pythonPath, comfyUIArgs, {
cwd: comfyUIWorkspace,
stdio: 'pipe',
});
const version = await app.getVersion();
sendKoboldOutput(`ComfyUI started via Gerbil v${version}`);
if (comfyUIProcess.stdout) {
comfyUIProcess.stdout.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString('utf8'), true);
});
}
if (comfyUIProcess.stderr) {
comfyUIProcess.stderr.on('data', (data: Buffer) => {
sendKoboldOutput(data.toString('utf8'), true);
});
}
comfyUIProcess.on('exit', (code: number | null, signal: string | null) => {
if (code === null && signal) {
sendKoboldOutput(`ComfyUI process terminated by signal: ${signal}`);
} else {
sendKoboldOutput(`ComfyUI process exited with code: ${code}`);
}
comfyUIProcess = null;
});
comfyUIProcess.on('error', (error: Error) => {
logError('ComfyUI process error', error);
comfyUIProcess = null;
});
await waitForComfyUIToStart();
} catch (error) {
logError('Failed to start ComfyUI', error as Error);
throw error;
}
}
export const stopFrontend = () => terminateProcess(comfyUIProcess);