gerbil/src/main/modules/comfyui.ts

546 lines
17 KiB
TypeScript

import { spawn } from 'child_process';
import { join } from 'path';
import { access } from 'fs/promises';
import type { ChildProcess } from 'child_process';
import yauzl from 'yauzl';
import { logError } from './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 { getAppVersion, ensureDir } from '@/utils/node/fs';
import { getGPUData } from '@/utils/node/gpu';
import { getUvEnvironment } from './dependencies';
let comfyUIProcess: ChildProcess | null = null;
process.on('SIGINT', () => {
void cleanup();
});
process.on('SIGTERM', () => {
void cleanup();
});
async function getPyTorchInstallArgs(pythonPath: string) {
const args = [
'pip',
'install',
'--python',
pythonPath,
'torch',
'torchvision',
'torchaudio',
];
try {
const gpus = await getGPUData();
const hasAMD = gpus.some((gpu) => gpu.deviceName.includes('AMD'));
const hasNVIDIA = gpus.some((gpu) => gpu.deviceName.includes('NVIDIA'));
if (hasAMD) {
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 {
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 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);
if (!isComfyUIInstalled) {
sendKoboldOutput('ComfyUI not found, installing via uv...');
const env = await getUvEnvironment();
sendKoboldOutput(
'Creating virtual environment and installing ComfyUI dependencies...'
);
const installProcess = spawn(
'uv',
['venv', '--python', '3.11', join(comfyUIWorkspace, '.venv')],
{
stdio: 'pipe',
env,
}
);
return new Promise<void>((resolve, reject) => {
installProcess.on('exit', async (code) => {
if (code === 0) {
try {
sendKoboldOutput(
'Virtual environment created, downloading ComfyUI...'
);
const response = await fetch(GITHUB_API.COMFYUI_DOWNLOAD_URL);
if (!response.ok) {
throw new Error(
`Failed to download ComfyUI: ${response.statusText}`
);
}
const fs = await import('fs/promises');
const path = await import('path');
const { createWriteStream } = await import('fs');
const zipPath = path.join(comfyUIWorkspace, '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 fs.stat(zipPath);
sendKoboldOutput(
`ComfyUI downloaded (${Math.round(zipStats.size / 1024 / 1024)}MB), extracting...`
);
try {
await new Promise<void>((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
if (err) {
reject(err);
return;
}
if (!zipfile) {
reject(new Error('Failed to open zip file'));
return;
}
zipfile.readEntry();
zipfile.on('entry', (entry) => {
if (/\/$/.test(entry.fileName)) {
zipfile.readEntry();
} else {
zipfile.openReadStream(entry, (err, readStream) => {
if (err) {
reject(err);
return;
}
if (!readStream) {
reject(new Error('Failed to create read stream'));
return;
}
const fileName = entry.fileName.replace(/^[^/]+\//, '');
const outputPath = path.join(
comfyUIWorkspace,
fileName
);
fs.mkdir(path.dirname(outputPath), { recursive: true })
.then(() => {
const writeStream = createWriteStream(outputPath);
readStream.pipe(writeStream);
writeStream.on('close', () => {
zipfile.readEntry();
});
writeStream.on('error', reject);
})
.catch(reject);
});
}
});
zipfile.on('end', () => {
resolve();
});
zipfile.on('error', reject);
});
});
// eslint-disable-next-line no-restricted-syntax
await fs.unlink(zipPath);
sendKoboldOutput('ComfyUI extracted, installing dependencies...');
const requirementsPath = join(
comfyUIWorkspace,
'requirements.txt'
);
const requirementsExists = await fs
.access(requirementsPath)
.then(() => true)
.catch(() => false);
if (!requirementsExists) {
throw new Error(
'requirements.txt not found in ComfyUI directory'
);
}
const pythonPath = join(
comfyUIWorkspace,
'.venv',
'bin',
'python'
);
const torchInstallArgs = await getPyTorchInstallArgs(pythonPath);
const torchInstallProcess = spawn('uv', torchInstallArgs, {
stdio: 'pipe',
env,
});
torchInstallProcess.on('exit', (torchCode: number | null) => {
if (torchCode === 0) {
sendKoboldOutput(
'PyTorch with ROCm installed, installing remaining dependencies...'
);
const pipInstallProcess = spawn(
'uv',
[
'pip',
'install',
'--python',
join(comfyUIWorkspace, '.venv', 'bin', 'python'),
'-r',
requirementsPath,
],
{
stdio: 'pipe',
env,
}
);
pipInstallProcess.on('exit', async (pipCode) => {
if (pipCode === 0) {
sendKoboldOutput(
'Dependencies installation completed, verifying...'
);
const verifyProcess = spawn(
join(comfyUIWorkspace, '.venv', 'bin', 'python'),
[
'-c',
'import einops; print("einops found:", einops.__version__)',
],
{
stdio: 'pipe',
env,
}
);
verifyProcess.on('exit', (verifyCode) => {
if (verifyCode === 0) {
sendKoboldOutput('ComfyUI installed successfully!');
resolve();
} else {
sendKoboldOutput(
'Warning: einops verification failed, but continuing...'
);
resolve();
}
});
if (verifyProcess.stdout) {
verifyProcess.stdout.on('data', (data: Buffer) => {
sendKoboldOutput(
`Verify: ${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);
});
}
} catch (error) {
reject(
new Error(
`Failed to extract ComfyUI: ${error instanceof Error ? error.message : String(error)}`
)
);
}
} catch (error) {
reject(
new Error(
`Failed to download ComfyUI: ${error instanceof Error ? error.message : 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);
});
}
});
} else {
sendKoboldOutput('ComfyUI found in workspace');
}
}
async function waitForComfyUIToStart() {
sendKoboldOutput('Waiting for ComfyUI to start...');
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,
isImageMode,
} = parseKoboldConfig(args);
if (!isImageMode) {
throw new Error('ComfyUI requires image generation mode to be enabled');
}
const [, appVersion] = await Promise.all([stopFrontend(), getAppVersion()]);
sendKoboldOutput(
`Preparing ComfyUI to connect at ${koboldHost}:${koboldPort} for image generation...`
);
const koboldUrl = `http://${koboldHost}:${koboldPort}`;
await ensureComfyUIInstalled();
const installDir = getInstallDir();
const comfyUIDataDir = join(installDir, 'comfyui-data');
const comfyUIWorkspace = join(installDir, 'comfyui-workspace');
const comfyUIMainPath = join(comfyUIWorkspace, 'main.py');
await Promise.all([ensureDir(comfyUIDataDir), ensureDir(comfyUIWorkspace)]);
const comfyUIArgs = [
comfyUIMainPath,
'--port',
config.port.toString(),
'--disable-auto-launch',
'--listen',
'0.0.0.0',
];
sendKoboldOutput('Starting ComfyUI directly...');
const envConfig: Record<string, string> = {
KOBOLDCPP_API_URL: `${koboldUrl}/comfyapi/v1`,
USER_AGENT: `Gerbil/${appVersion}`,
};
const env = await getUvEnvironment();
Object.assign(env, envConfig);
const pythonPath = join(comfyUIWorkspace, '.venv', 'bin', 'python');
comfyUIProcess = spawn(pythonPath, comfyUIArgs, {
stdio: 'pipe',
env,
cwd: comfyUIWorkspace,
});
if (comfyUIProcess.stdout) {
comfyUIProcess.stdout.on('data', (data: Buffer) => {
try {
const output = data.toString('utf8');
sendKoboldOutput(output, true);
} catch (error) {
logError('Error processing stdout data:', error as Error);
}
});
}
if (comfyUIProcess.stderr) {
comfyUIProcess.stderr.on('data', (data: Buffer) => {
try {
const output = data.toString('utf8');
sendKoboldOutput(output, true);
} catch (error) {
logError('Error processing stderr data:', error as Error);
}
});
}
comfyUIProcess.on('exit', (code: number | null, signal: string | null) => {
const message = signal
? `ComfyUI terminated with signal ${signal}`
: `ComfyUI exited with code ${code}`;
sendKoboldOutput(message);
comfyUIProcess = null;
});
comfyUIProcess.on('error', (error) => {
logError('ComfyUI process error:', error);
sendKoboldOutput(`ComfyUI error: ${error.message}`);
comfyUIProcess = null;
});
await waitForComfyUIToStart();
sendKoboldOutput(`ComfyUI is ready and auto-configured!`);
sendKoboldOutput(`Access ComfyUI at: http://localhost:${config.port}`);
sendKoboldOutput(
`Image Generation API: ${koboldUrl}/comfyapi/v1 (auto-configured)`
);
} catch (error) {
logError('Failed to start ComfyUI frontend:', error as Error);
sendKoboldOutput(`Failed to start ComfyUI: ${(error as Error).message}`);
comfyUIProcess = null;
throw error;
}
}
export async function stopFrontend() {
if (comfyUIProcess) {
sendKoboldOutput('Stopping ComfyUI...');
try {
await terminateProcess(comfyUIProcess, {
logError: (message, error) => logError(message, error),
});
sendKoboldOutput('ComfyUI stopped');
} catch (error) {
logError('Error stopping ComfyUI:', error as Error);
}
comfyUIProcess = null;
}
}
export async function cleanup() {
await stopFrontend();
}