1030 lines
34 KiB
JavaScript
1030 lines
34 KiB
JavaScript
#!/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);
|
||
});
|