Files
TrueGrowth/scripts/verify-comfyui-image-flow.mjs

1030 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process';
import http from 'node:http';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL as pathToFileUrl } from 'node:url';
const ROOT = process.cwd();
const PORT = Number(process.env.TRUEGROWTH_LOCAL_API_PORT || 48177);
const BASE_URL = `http://127.0.0.1:${PORT}`;
const MOCK_COMFYUI_PORT = Number(
process.env.TRUEGROWTH_MOCK_COMFYUI_PORT || 58188
);
const MOCK_COMFYUI_BASE_URL = `http://127.0.0.1:${MOCK_COMFYUI_PORT}`;
const TIMEOUT_MS = Number(process.env.TRUEGROWTH_VERIFY_TIMEOUT_MS || 90_000);
const FLUX_EXPECTED_BYTES = 17_236_328_572;
const QWEN_EXPECTED_BYTES = 57_954_375_058;
const COMFYUI_DIR = path.resolve(
process.env.TRUEGROWTH_COMFYUI_DIR || path.join(ROOT, 'vendor', 'ComfyUI')
);
const FLUX_FILE = path.join(
COMFYUI_DIR,
'models',
'checkpoints',
'flux1-schnell-fp8.safetensors'
);
const QWEN_DIR = path.join(COMFYUI_DIR, 'models', 'diffusers', 'Qwen-Image');
const STATIC_FILES = [
'scripts/truegrowth-local-api.mjs',
'apps/web/src/workbench/WorkbenchShell.tsx',
'apps/web/src/workbench/local-runtime-client.ts',
'packages/drawnix/src/utils/runtime-model-discovery.ts',
'packages/drawnix/src/services/media-executor/fallback-executor.ts',
];
const TINY_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
'base64'
);
function createMockComfyUiDir() {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'truegrowth-mock-comfyui-'));
fs.mkdirSync(path.join(root, 'models', 'checkpoints'), { recursive: true });
fs.mkdirSync(path.join(root, 'models', 'diffusers', 'Qwen-Image'), {
recursive: true,
});
fs.mkdirSync(path.join(root, 'models', 'diffusers', 'Qwen-Image', 'transformer'), {
recursive: true,
});
fs.mkdirSync(path.join(root, 'models', 'diffusers', 'Qwen-Image', 'text_encoder'), {
recursive: true,
});
fs.mkdirSync(path.join(root, 'models', 'diffusers', 'Qwen-Image', 'vae'), {
recursive: true,
});
fs.writeFileSync(path.join(root, 'main.py'), '# mock comfyui\n');
fs.writeFileSync(path.join(root, 'requirements.txt'), '# mock requirements\n');
fs.writeFileSync(
path.join(root, 'models', 'checkpoints', 'flux1-schnell-fp8.safetensors'),
'mock flux'
);
fs.writeFileSync(
path.join(root, 'models', 'diffusers', 'Qwen-Image', 'model_index.json'),
'{}\n'
);
fs.writeFileSync(
path.join(
root,
'models',
'diffusers',
'Qwen-Image',
'transformer',
'diffusion_pytorch_model.safetensors.index.json'
),
'{}\n'
);
fs.writeFileSync(
path.join(
root,
'models',
'diffusers',
'Qwen-Image',
'text_encoder',
'model.safetensors.index.json'
),
'{}\n'
);
fs.writeFileSync(
path.join(
root,
'models',
'diffusers',
'Qwen-Image',
'vae',
'diffusion_pytorch_model.safetensors'
),
'mock vae'
);
return root;
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}
async function requestJson(url, options = {}) {
const response = await fetch(url, options);
const text = await response.text();
const json = safeJson(text);
if (!response.ok) {
throw new Error(
`${options.method || 'GET'} ${url} failed ${response.status}: ${text}`
);
}
return json ?? text;
}
async function apiIsReady() {
try {
await requestJson(`${BASE_URL}/local-api/health`);
return true;
} catch {
return false;
}
}
async function waitForApi() {
const startedAt = Date.now();
while (Date.now() - startedAt < TIMEOUT_MS) {
if (await apiIsReady()) {
return;
}
await delay(300);
}
throw new Error('Local API did not become ready in time.');
}
function startMockComfyUiServer() {
const requests = {
prompts: [],
views: [],
};
const server = http.createServer(async (request, response) => {
const url = new URL(request.url || '/', MOCK_COMFYUI_BASE_URL);
const sendJson = (status, payload) => {
response.writeHead(status, { 'content-type': 'application/json' });
response.end(JSON.stringify(payload));
};
if (request.method === 'GET' && url.pathname === '/system_stats') {
sendJson(200, {
system: { os: 'mock', python_version: '3.11' },
devices: [{ name: 'Mock GPU', type: 'cuda', vram_total: 24_000 }],
});
return;
}
if (request.method === 'GET' && url.pathname === '/object_info') {
sendJson(200, {
CheckpointLoaderSimple: {
input: {
required: {
ckpt_name: [
['flux1-schnell-fp8.safetensors', 'sd_xl_base_1.0.safetensors'],
],
},
},
},
CLIPTextEncode: { input: { required: {} } },
EmptySD3LatentImage: { input: { required: {} } },
KSampler: { input: { required: {} } },
VAEDecode: { input: { required: {} } },
SaveImage: { input: { required: {} } },
TrueGrowthQwenImageGenerate: {
input: { required: { model_name: [['Qwen-Image']] } },
},
});
return;
}
if (request.method === 'POST' && url.pathname === '/prompt') {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const bodyText = Buffer.concat(chunks).toString('utf8');
const body = safeJson(bodyText) || {};
requests.prompts.push(body.prompt || body);
sendJson(200, { prompt_id: `mock-prompt-${requests.prompts.length}` });
return;
}
if (request.method === 'GET' && url.pathname.startsWith('/history/')) {
const promptId = decodeURIComponent(url.pathname.split('/').pop() || '');
sendJson(200, {
[promptId]: {
outputs: {
9: {
images: [
{
filename: 'truegrowth_mock_00001_.png',
subfolder: '',
type: 'output',
},
],
},
},
},
});
return;
}
if (request.method === 'GET' && url.pathname === '/view') {
requests.views.push(Object.fromEntries(url.searchParams.entries()));
response.writeHead(200, { 'content-type': 'image/png' });
response.end(TINY_PNG);
return;
}
sendJson(404, { error: 'not_found', path: url.pathname });
});
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(MOCK_COMFYUI_PORT, '127.0.0.1', () => {
server.off('error', reject);
resolve({ server, requests });
});
});
}
async function waitForTaskCompleted(taskId) {
const startedAt = Date.now();
let lastTask = null;
while (Date.now() - startedAt < TIMEOUT_MS) {
const payload = await requestJson(`${BASE_URL}/local-api/tasks/${taskId}`);
const task = payload.task || payload.item || payload;
lastTask = task;
if (task.status === 'completed') return task;
if (task.status === 'failed') {
throw new Error(`Task failed: ${task.error || 'unknown error'}`);
}
await delay(300);
}
throw new Error(
`Task ${taskId} did not complete in time. Last state: ${JSON.stringify(
lastTask
)}`
);
}
function fileSizeLabel(filePath) {
try {
const bytes = fs.statSync(filePath).size;
if (bytes > 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
if (bytes > 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(2)} MB`;
return `${bytes} B`;
} catch {
return null;
}
}
function qwenSizeLabel() {
if (!fs.existsSync(QWEN_DIR)) return null;
let bytes = 0;
const stack = [QWEN_DIR];
while (stack.length) {
const current = stack.pop();
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
} else if (entry.isFile()) {
bytes += fs.statSync(fullPath).size;
}
}
}
return bytes > 1024 ** 3
? `${(bytes / 1024 ** 3).toFixed(2)} GB`
: `${(bytes / 1024 ** 2).toFixed(2)} MB`;
}
function bytesLabel(bytes) {
if (!Number.isFinite(Number(bytes))) return null;
return `${(Number(bytes) / 1024 ** 3).toFixed(2)} GB`;
}
function diskAvailableBytes(targetPath) {
try {
const existingPath = fs.existsSync(targetPath)
? targetPath
: path.dirname(targetPath);
const result = spawnSync('df', ['-k', existingPath], { encoding: 'utf8' });
if (result.error) throw result.error;
const output = result.stdout || '';
const line = output.trim().split(/\r?\n/).pop() || '';
const parts = line.trim().split(/\s+/);
const availableKb = Number(parts[3] || 0);
return Number.isFinite(availableKb) ? availableKb * 1024 : null;
} catch {
return null;
}
}
function runStaticCheck() {
const contents = Object.fromEntries(
STATIC_FILES.map((file) => [
file,
fs.readFileSync(path.join(ROOT, file), 'utf8'),
])
);
const api = contents['scripts/truegrowth-local-api.mjs'];
const discovery =
contents['packages/drawnix/src/utils/runtime-model-discovery.ts'];
const executor =
contents[
'packages/drawnix/src/services/media-executor/fallback-executor.ts'
];
const workbench = contents['apps/web/src/workbench/WorkbenchShell.tsx'];
const client = contents['apps/web/src/workbench/local-runtime-client.ts'];
assert(
api.includes(
'https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors'
),
'FLUX official download URL is missing from local API.'
);
assert(
api.includes(
'git clone --depth 1 https://github.com/comfyanonymous/ComfyUI.git vendor/ComfyUI'
) &&
api.includes("'--depth'") &&
api.includes("'1'"),
'ComfyUI automatic install should use a shallow official GitHub clone.'
);
assert(
api.includes('function pythonVersionInfo') &&
api.includes('function resolveComfyUiPythonCommand') &&
api.includes("path.join(os.homedir(), 'miniconda3', 'bin', 'python3.12')") &&
api.includes('Python 3.10+') &&
api.includes('pythonVersion.ok'),
'ComfyUI setup should auto-select and require Python 3.10+ before automatic install.'
);
assert(
api.includes("repo: 'Qwen/Qwen-Image'"),
'Qwen-Image official repo is missing from local API.'
);
assert(
api.includes('function getTrueGrowthComfyUiCustomNodeDir') &&
api.includes('class TrueGrowthQwenImageGenerate') &&
api.includes('"TrueGrowthQwenImageGenerate": TrueGrowthQwenImageGenerate') &&
api.includes('huggingface_hub>=0.24.0'),
'TrueGrowth Qwen-Image ComfyUI custom node is missing.'
);
assert(
api.includes("templateId: 'flux-fp8-checkpoint-text-to-image'"),
'FLUX FP8 template is missing from local API.'
);
assert(
api.includes("id: 'qwen-image-text-to-image'") &&
api.includes('function buildQwenImageComfyUiWorkflow'),
'Qwen-Image ComfyUI template or workflow builder is missing.'
);
assert(
api.includes('function buildFluxFp8CheckpointComfyUiWorkflow'),
'FLUX FP8 ComfyUI workflow builder is missing.'
);
assert(
api.includes('minFreeBytes: 18 * 1024 * 1024 * 1024') &&
api.includes('minFreeBytes: 70 * 1024 * 1024 * 1024'),
'Model download disk preflight thresholds are missing.'
);
assert(
api.includes('expectedBytes: 17_236_328_572') &&
api.includes('expectedBytes: 57_954_375_058') &&
api.includes('python3 -m huggingface_hub download Qwen/Qwen-Image'),
'Official FLUX/Qwen download sizes or manual commands are missing.'
);
assert(
api.includes("'-m',") &&
api.includes("'huggingface_hub'") &&
api.includes("'download'") &&
api.includes("downloadSpec.repo"),
'Qwen-Image snapshot download does not fall back to python -m huggingface_hub.'
);
assert(
api.includes('function comfyUiModelDownloadPreflight') &&
api.includes('downloadPreflight:'),
'Model Center assets do not expose ComfyUI download preflight state.'
);
assert(
api.includes('function getComfyUiVendorDir') &&
api.includes("pathname === '/local-api/comfyui/setup'") &&
api.includes('missing_vendor_path') &&
api.includes('invalid_comfyui_dir'),
'Local API does not let Model Center register an existing ComfyUI directory.'
);
assert(
api.includes('function installComfyUiImageAssetFromPath') &&
api.includes('function linkOrCopyComfyUiModelSource') &&
api.includes('function validateAigcPanelModelAssetLocalPath') &&
api.includes('function isCompleteQwenImageDiffusersModel') &&
api.includes("'model_index.json'") &&
api.includes("path.join('transformer', 'diffusion_pytorch_model.safetensors.index.json')") &&
api.includes('validate-local') &&
api.includes("asset.runtimeKind === 'comfyui-image'"),
'Local API does not install existing FLUX/Qwen files into ComfyUI model directories.'
);
assert(
api.includes('function huggingFaceCacheStatus') &&
api.includes('cacheStatus,'),
'ComfyUI model preflight does not report Hugging Face cache status.'
);
assert(
discovery.includes('flux-fp8-checkpoint-text-to-image'),
'FLUX FP8 model is missing from runtime model discovery.'
);
assert(
discovery.includes('qwen-image-text-to-image') &&
discovery.includes('Qwen-Image 本地生图'),
'Qwen-Image model is missing from runtime model discovery.'
);
assert(
!discovery.includes('...LOCAL_COMFYUI_MODEL_IDS'),
'Local ComfyUI templates should not be selected by default before model files are applied.'
);
assert(
executor.includes("templateId: 'flux-fp8-checkpoint-text-to-image'"),
'Media executor does not route FLUX FP8 selections to the FLUX FP8 template.'
);
assert(
executor.includes("templateId: 'qwen-image-text-to-image'"),
'Media executor does not route Qwen-Image selections to the Qwen template.'
);
assert(
workbench.includes('图片模型本地供应商 · ComfyUI') &&
workbench.includes('文件约 17.2GB'),
'Model Center ComfyUI guide or FLUX size copy is missing.'
);
assert(
workbench.includes('在模型中心一键安装 ComfyUI') &&
workbench.includes('没有安装 ComfyUI 时,从这里开始') &&
workbench.includes('一键安装 ComfyUI'),
'Model Center does not clearly guide first-time ComfyUI installation.'
);
assert(
workbench.includes('Python 3.10+'),
'Model Center does not explain the Python 3.10+ ComfyUI requirement.'
);
assert(
workbench.includes('downloadPreflight') && workbench.includes('空间不足'),
'Model Center does not surface ComfyUI download blockers.'
);
assert(
workbench.includes('检测到未完成缓存') &&
workbench.includes('downloadPreflight.cacheStatus'),
'Model Center does not surface partial Hugging Face cache state.'
);
assert(
workbench.includes('官方大小') &&
workbench.includes('tg-comfy-download-guide') &&
workbench.includes('python3 -m huggingface_hub download Qwen/Qwen-Image'),
'Model Center does not show official model sizes and manual ComfyUI download commands.'
);
assert(
workbench.includes('已有 ComfyUI 目录') &&
workbench.includes('configureComfyUiRuntime') &&
workbench.includes('登记目录'),
'Model Center does not expose existing ComfyUI directory registration.'
);
assert(
workbench.includes('FLUX safetensors 文件或 Qwen-Image 目录') &&
workbench.includes('.safetensors') &&
workbench.includes('检查路径') &&
workbench.includes('ComfyUI 接入预览'),
'Model Center import guide does not explain existing local image model import.'
);
assert(
client.includes('minFreeBytes?: number') &&
client.includes('cacheStatus?:'),
'Frontend client type does not expose model disk/cache requirement.'
);
const availableBytes = diskAvailableBytes(COMFYUI_DIR);
console.log(
JSON.stringify(
{
ok: true,
mode: 'static',
officialDownloads: {
flux: {
repo: 'https://huggingface.co/Comfy-Org/flux1-schnell',
file: 'https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors',
expectedSize: bytesLabel(FLUX_EXPECTED_BYTES),
templateId: 'flux-fp8-checkpoint-text-to-image',
},
qwenImage: {
repo: 'https://huggingface.co/Qwen/Qwen-Image',
expectedRepoSize: bytesLabel(QWEN_EXPECTED_BYTES),
target: 'models/diffusers/Qwen-Image',
templateId: 'qwen-image-text-to-image',
customNode: 'TrueGrowthQwenImageGenerate',
runnable: true,
},
},
disk: {
comfyUiDir: COMFYUI_DIR,
available: bytesLabel(availableBytes),
canDownloadFlux: Boolean(
availableBytes && availableBytes >= 18 * 1024 ** 3
),
canDownloadQwenImage: Boolean(
availableBytes && availableBytes >= 70 * 1024 ** 3
),
},
},
null,
2
)
);
}
async function runWorkflowCheck() {
const {
assertComfyUiWorkflowDimensionsForVerification,
createComfyUiWorkflowForVerification,
getComfyUiImageSizeForVerification,
} = await import(
pathToFileUrl(path.join(ROOT, 'scripts', 'truegrowth-local-api.mjs')).href
);
const dimensionCases = [
{ size: '16:9', expected: { width: 1824, height: 1024 } },
{ size: '16x9', expected: { width: 1824, height: 1024 } },
{ size: '9:16', expected: { width: 1024, height: 1824 } },
{ size: '1x1', expected: { width: 1024, height: 1024 } },
{ size: '1536x1024', expected: { width: 1536, height: 1024 } },
];
for (const item of dimensionCases) {
const actual = getComfyUiImageSizeForVerification(item.size, '1024x1024');
assert(
actual.width === item.expected.width &&
actual.height === item.expected.height,
`ComfyUI size ${item.size} resolved to ${actual.width}x${actual.height}, expected ${item.expected.width}x${item.expected.height}.`
);
}
const fluxWorkflow = createComfyUiWorkflowForVerification({
templateId: 'flux-fp8-checkpoint-text-to-image',
modelId: 'flux1-schnell-fp8.safetensors',
prompt: 'orange product poster',
size: '1024x1024',
steps: 4,
cfg: 1,
seed: 42,
});
const qwenWorkflow = createComfyUiWorkflowForVerification({
templateId: 'qwen-image-text-to-image',
modelId: 'Qwen-Image',
prompt: '中文海报,标题为增长引擎',
negativePrompt: '低清晰度',
size: '1024x1024',
steps: 20,
cfg: 4,
seed: 42,
});
assertComfyUiWorkflowDimensionsForVerification(
{
templateId: 'flux-fp8-checkpoint-text-to-image',
modelId: 'flux1-schnell-fp8.safetensors',
prompt: 'orange product poster',
size: '16x9',
steps: 4,
cfg: 1,
seed: 42,
},
{ width: 1824, height: 1024 }
);
assertComfyUiWorkflowDimensionsForVerification(
{
templateId: 'qwen-image-text-to-image',
modelId: 'Qwen-Image',
prompt: '中文竖版海报',
aspectRatio: '9:16',
steps: 20,
cfg: 4,
seed: 42,
},
{ width: 1024, height: 1824 }
);
assertComfyUiWorkflowDimensionsForVerification(
{
templateId: 'qwen-image-text-to-image',
modelId: 'Qwen-Image',
prompt: '固定尺寸海报',
width: 1280,
height: 720,
steps: 20,
cfg: 4,
seed: 42,
},
{ width: 1280, height: 720 }
);
assert(
Object.values(fluxWorkflow).some(
(node) =>
node.class_type === 'CheckpointLoaderSimple' &&
node.inputs?.ckpt_name === 'flux1-schnell-fp8.safetensors'
),
'FLUX workflow does not load the FP8 checkpoint.'
);
assert(
Object.values(fluxWorkflow).some((node) => node.class_type === 'KSampler'),
'FLUX workflow is missing KSampler.'
);
assert(
Object.values(qwenWorkflow).some(
(node) =>
node.class_type === 'TrueGrowthQwenImageGenerate' &&
node.inputs?.model_name === 'Qwen-Image' &&
node.inputs?.negative_prompt === '低清晰度'
),
'Qwen workflow does not use the TrueGrowth Qwen-Image node.'
);
assert(
Object.values(qwenWorkflow).some(
(node) =>
node.class_type === 'SaveImage' &&
String(node.inputs?.filename_prefix || '').includes('qwen_image')
),
'Qwen workflow does not save generated image output.'
);
console.log(
JSON.stringify(
{
ok: true,
mode: 'workflow',
dimensionCases,
fluxNodes: Object.values(fluxWorkflow).map((node) => node.class_type),
qwenNodes: Object.values(qwenWorkflow).map((node) => node.class_type),
},
null,
2
)
);
}
async function main() {
if (process.argv.includes('--static')) {
runStaticCheck();
return;
}
if (process.argv.includes('--workflow')) {
await runWorkflowCheck();
return;
}
if (process.argv.includes('--mock-comfyui')) {
let mock = null;
let child = null;
let stderr = '';
let stdout = '';
let childExit = null;
let mockComfyUiDir = null;
try {
mockComfyUiDir = createMockComfyUiDir();
mock = await startMockComfyUiServer();
child = spawn(process.execPath, ['scripts/truegrowth-local-api.mjs'], {
cwd: ROOT,
env: {
...process.env,
TRUEGROWTH_LOCAL_API_PORT: String(PORT),
TRUEGROWTH_COMFYUI_BASE_URL: MOCK_COMFYUI_BASE_URL,
TRUEGROWTH_COMFYUI_DIR: mockComfyUiDir,
TRUEGROWTH_COMFYUI_HISTORY_ATTEMPTS: '10',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
child.on('exit', (code, signal) => {
childExit = { code, signal };
});
try {
await waitForApi();
} catch (error) {
if (/listen EPERM|EACCES|operation not permitted/i.test(stderr)) {
throw new Error(
`Local API could not listen on ${BASE_URL}. Current environment blocked localhost binding. Raw error: ${stderr.trim()}`
);
}
throw new Error(
`Local API did not become ready in time. exit: ${
childExit ? JSON.stringify(childExit) : 'still running'
} stdout: ${stdout.trim()} stderr: ${stderr.trim()}`
);
}
const status = await requestJson(`${BASE_URL}/local-api/comfyui/status`);
const statusConnection = status.connection || status;
const statusTemplates = statusConnection.templates || status.templates || [];
assert(
statusConnection.connected,
`Mock ComfyUI status should be connected. Payload: ${JSON.stringify(
status
)}`
);
assert(
statusTemplates.some(
(item) =>
item.id === 'flux-fp8-checkpoint-text-to-image' && item.available
),
'FLUX FP8 template should be available against mock ComfyUI.'
);
assert(
statusTemplates.some(
(item) =>
item.id === 'qwen-image-text-to-image' && item.available
),
'Qwen-Image template should be available against mock ComfyUI.'
);
const generated = [];
for (const input of [
{
label: 'flux',
templateId: 'flux-fp8-checkpoint-text-to-image',
modelId: 'flux1-schnell-fp8.safetensors',
prompt: 'a clean orange product poster',
},
{
label: 'qwen',
templateId: 'qwen-image-text-to-image',
modelId: 'Qwen-Image',
prompt: '中文海报,标题为增长引擎',
},
]) {
const submit = await requestJson(
`${BASE_URL}/local-api/comfyui/generate-image`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
title: `Mock ${input.label}`,
payload: {
prompt: input.prompt,
templateId: input.templateId,
modelId: input.modelId,
size: '1024x1024',
steps: input.label === 'flux' ? 4 : 20,
cfg: input.label === 'flux' ? 1 : 4,
seed: 42,
},
}),
}
);
assert(submit.task?.id, `${input.label} task was not created.`);
const task = await waitForTaskCompleted(submit.task.id);
assert(
Array.isArray(task.assetIds) && task.assetIds.length > 0,
`${input.label} task did not create an asset.`
);
generated.push({
label: input.label,
taskId: task.id,
assetIds: task.assetIds,
});
}
const assets = await requestJson(`${BASE_URL}/local-api/assets`);
for (const item of generated) {
const assetId = item.assetIds[0];
const asset = assets.items?.find((candidate) => candidate.id === assetId);
assert(asset, `${item.label} generated asset is missing from library.`);
assert(
asset.type === 'image',
`${item.label} generated asset should be an image.`
);
}
assert(
mock.requests.prompts.some((workflow) =>
Object.values(workflow || {}).some(
(node) => node?.class_type === 'TrueGrowthQwenImageGenerate'
)
),
'Mock ComfyUI did not receive the Qwen custom node workflow.'
);
assert(
mock.requests.prompts.some((workflow) =>
Object.values(workflow || {}).some(
(node) => node?.class_type === 'CheckpointLoaderSimple'
)
),
'Mock ComfyUI did not receive the FLUX checkpoint workflow.'
);
console.log(
JSON.stringify(
{
ok: true,
mode: 'mock-comfyui',
baseUrl: BASE_URL,
comfyUiBaseUrl: MOCK_COMFYUI_BASE_URL,
generated,
promptCount: mock.requests.prompts.length,
viewCount: mock.requests.views.length,
},
null,
2
)
);
} finally {
if (child) child.kill('SIGTERM');
if (mock) await new Promise((resolve) => mock.server.close(resolve));
if (mockComfyUiDir) {
fs.rmSync(mockComfyUiDir, { recursive: true, force: true });
}
}
return;
}
let child = null;
let stderr = '';
try {
if (!(await apiIsReady())) {
child = spawn(process.execPath, ['scripts/truegrowth-local-api.mjs'], {
cwd: ROOT,
env: { ...process.env, TRUEGROWTH_LOCAL_API_PORT: String(PORT) },
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
}
try {
await waitForApi();
} catch (error) {
if (/listen EPERM|EACCES|operation not permitted/i.test(stderr)) {
throw new Error(
`Local API could not listen on ${BASE_URL}. Current environment blocked localhost binding. Raw error: ${stderr.trim()}`
);
}
throw error;
}
const setup = await requestJson(`${BASE_URL}/local-api/comfyui/setup`);
const assets = await requestJson(
`${BASE_URL}/local-api/aigcpanel/model-assets`
);
const templates = await requestJson(
`${BASE_URL}/local-api/comfyui/templates`
);
const comfyModels = await requestJson(
`${BASE_URL}/local-api/comfyui/models`
);
const imageAssets = Array.isArray(assets.items)
? assets.items.filter((item) => item.family === 'image')
: [];
const fluxAsset = imageAssets.find(
(item) => item.id === 'flux-1-schnell-local'
);
const qwenAsset = imageAssets.find(
(item) => item.id === 'qwen-image-local'
);
const installedImageModels = Array.isArray(assets.installedModels)
? assets.installedModels.filter((item) => item.family === 'image')
: [];
const fluxExists = fs.existsSync(FLUX_FILE);
const qwenExists = fs.existsSync(QWEN_DIR);
const fluxLoaded = Object.values(comfyModels.models || {})
.flat()
.includes('flux1-schnell-fp8.safetensors');
const qwenLoaded = Object.values(comfyModels.models || {})
.flat()
.includes('Qwen-Image');
assert(
setup.setup?.vendorPath || setup.connection?.setup?.vendorPath,
'ComfyUI setup payload is missing.'
);
assert(fluxAsset, 'FLUX model asset is missing from model center data.');
assert(
qwenAsset,
'Qwen-Image model asset is missing from model center data.'
);
assert(
fluxAsset.comfyUiDownload?.url?.includes('Comfy-Org/flux1-schnell'),
'FLUX download URL is not wired to the ComfyUI official checkpoint.'
);
assert(
fluxAsset.comfyUiDownload?.templateId ===
'flux-fp8-checkpoint-text-to-image',
'FLUX FP8 checkpoint is not wired to the dedicated ComfyUI workflow template.'
);
assert(
fluxAsset.comfyUiDownload?.minFreeBytes >= 18 * 1024 ** 3,
'FLUX download preflight does not require enough free disk space.'
);
assert(
qwenAsset.comfyUiDownload?.repo === 'Qwen/Qwen-Image',
'Qwen-Image download spec is not wired to the official Hugging Face repo.'
);
assert(
qwenAsset.comfyUiDownload?.runnable === true,
'Qwen-Image should be marked runnable because TrueGrowth installs a ComfyUI Qwen-Image node and workflow template.'
);
assert(
qwenAsset.comfyUiDownload?.minFreeBytes >= 70 * 1024 ** 3,
'Qwen-Image download preflight does not require enough free disk space.'
);
const availableBytes = diskAvailableBytes(COMFYUI_DIR);
const result = {
ok: true,
localApi: BASE_URL,
comfyUi: {
installed: Boolean(setup.setup?.installed),
running: Boolean(setup.connected),
baseUrl: setup.setup?.baseUrl || setup.connection?.baseUrl,
vendorPath:
setup.setup?.vendorPath || setup.connection?.setup?.vendorPath,
nodeCount: setup.connection?.nodeCount || 0,
},
models: {
flux: {
expectedPath: FLUX_FILE,
officialRepo: 'https://huggingface.co/Comfy-Org/flux1-schnell',
officialDownload:
'https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors',
expectedSize: bytesLabel(FLUX_EXPECTED_BYTES),
requiredFreeSpace: bytesLabel(
fluxAsset.comfyUiDownload?.minFreeBytes
),
exists: fluxExists,
size: fluxExists ? fileSizeLabel(FLUX_FILE) : null,
loadedByComfyUi: fluxLoaded,
assetInstalled: Boolean(fluxAsset.installed),
serviceStatus: fluxAsset.installedModel?.service?.status || null,
},
qwenImage: {
expectedPath: QWEN_DIR,
officialRepo: 'https://huggingface.co/Qwen/Qwen-Image',
expectedRepoSize: bytesLabel(QWEN_EXPECTED_BYTES),
requiredFreeSpace: bytesLabel(
qwenAsset.comfyUiDownload?.minFreeBytes
),
exists: qwenExists,
size: qwenExists ? qwenSizeLabel() : null,
loadedByComfyUi: qwenLoaded,
assetInstalled: Boolean(qwenAsset.installed),
serviceStatus: qwenAsset.installedModel?.service?.status || null,
runnable: qwenAsset.comfyUiDownload?.runnable !== false,
note: qwenAsset.comfyUiDownload?.note || null,
},
},
disk: {
comfyUiDir: COMFYUI_DIR,
available: bytesLabel(availableBytes),
canDownloadFlux: Boolean(
availableBytes &&
availableBytes >=
Number(fluxAsset.comfyUiDownload?.minFreeBytes || 0)
),
canDownloadQwenImage: Boolean(
availableBytes &&
availableBytes >=
Number(qwenAsset.comfyUiDownload?.minFreeBytes || 0)
),
},
modelCenter: {
installedImageModelCount: installedImageModels.length,
templates: Array.isArray(templates.items)
? templates.items.map((item) => ({
id: item.id,
available: item.available,
modelChoices: item.modelChoices || [],
missingNodes: item.missingNodes || [],
}))
: [],
},
nextSteps: [
fluxExists
? 'FLUX 文件已在 ComfyUI 目录,启动 ComfyUI 后刷新模型中心并应用到生图。'
: availableBytes &&
availableBytes <
Number(fluxAsset.comfyUiDownload?.minFreeBytes || 0)
? `当前磁盘可用约 ${bytesLabel(
availableBytes
)},不足以下载 FLUX FP8。请先清理到至少 ${bytesLabel(
fluxAsset.comfyUiDownload?.minFreeBytes
)}`
: '在模型中心下载 FLUX或把 flux1-schnell-fp8.safetensors 放入 models/checkpoints。',
qwenExists
? 'Qwen-Image 已在 diffusers 目录;生成前确认已安装 Qwen-Image 专用 ComfyUI 节点或工作流。'
: availableBytes &&
availableBytes <
Number(qwenAsset.comfyUiDownload?.minFreeBytes || 0)
? `当前磁盘可用约 ${bytesLabel(
availableBytes
)},不足以下载 Qwen-Image。请先清理到至少 ${bytesLabel(
qwenAsset.comfyUiDownload?.minFreeBytes
)}`
: '如需中文海报能力,在模型中心下载 Qwen-Image 到 models/diffusers/Qwen-Image。',
],
};
console.log(JSON.stringify(result, null, 2));
if (stderr.trim()) {
console.error(stderr.trim());
}
} finally {
if (child && !child.killed) {
child.kill('SIGTERM');
}
}
}
main().catch((error) => {
console.error(
error instanceof Error ? error.stack || error.message : String(error)
);
process.exit(1);
});