Files
sprite-ai/server/workflows.ts
T
2026-09-23 21:56:40 +03:00

159 lines
9.3 KiB
TypeScript

// Графы ComfyUI в API-формате. Только встроенные ноды ComfyUI 0.37.
import type { JobKind, Text2ImgInput } from '../shared/api.ts';
type Graph = Record<string, { class_type: string; inputs: Record<string, unknown> }>;
/** SDXL: промпт по шаблону (StringFormat, {a} — форма, {b} — цвета) → картинки в output/img2glb_src. */
export function text2imgGraph(o: {
template: string; shape: string; colors: string; negative: string; batch: number; seed: number;
}): Graph {
return {
'1': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: 'sd_xl_base_1.0.safetensors' } },
'2': { class_type: 'PrimitiveStringMultiline', inputs: { value: o.shape } },
'3': { class_type: 'PrimitiveStringMultiline', inputs: { value: o.colors } },
'4': { class_type: 'StringFormat', inputs: { 'values.a': ['2', 0], 'values.b': ['3', 0], f_string: o.template } },
'5': { class_type: 'PrimitiveStringMultiline', inputs: { value: o.negative } },
'6': { class_type: 'CLIPTextEncode', inputs: { text: ['4', 0], clip: ['1', 1] } },
'7': { class_type: 'CLIPTextEncode', inputs: { text: ['5', 0], clip: ['1', 1] } },
'8': { class_type: 'EmptyLatentImage', inputs: { width: 1024, height: 1024, batch_size: o.batch } },
'9': { class_type: 'KSampler', inputs: {
model: ['1', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['8', 0],
seed: o.seed, steps: 25, cfg: 7.0, sampler_name: 'dpmpp_2m', scheduler: 'karras', denoise: 1.0 } },
'10': { class_type: 'VAEDecode', inputs: { samples: ['9', 0], vae: ['1', 2] } },
'11': { class_type: 'SaveImage', inputs: { images: ['10', 0], filename_prefix: 'img2glb_src/character' } },
};
}
/** Подготовка для Hunyuan3D: удаление фона BiRefNet, кадрирование по маске в квадрат с отступом, заливка фона. */
export function cleanupGraph(o: {
image: string; prefix: string; size: number; pad: number; grow: number; background: string;
}): Graph {
return {
'1': { class_type: 'LoadImage', inputs: { image: o.image } },
'2': { class_type: 'LoadBackgroundRemovalModel', inputs: { bg_removal_name: 'BiRefNet-general.safetensors' } },
'3': { class_type: 'RemoveBackground', inputs: { bg_removal_model: ['2', 0], image: ['1', 0] } },
'4': { class_type: 'ImageCropToMask', inputs: {
images: ['1', 0], masks: ['3', 0], width: o.size, height: o.size, pad_factor: o.pad, grow_mask: o.grow, background: o.background } },
'5': { class_type: 'SaveImage', inputs: { images: ['4', 0], filename_prefix: o.prefix } },
};
}
/** Очищенная картинка → GLB через Hunyuan3D-2 mini + PNG-превью меша в ракурсе 3/4. */
export function modelGraph(o: {
image: string; prefix: string; seed: number; steps: number; octree: number; faces: number;
}): Graph {
return {
'1': { class_type: 'LoadImage', inputs: { image: o.image } },
'2': { class_type: 'ImageOnlyCheckpointLoader', inputs: { ckpt_name: 'hunyuan3d-dit-v2-mini_fp16.safetensors' } },
'3': { class_type: 'CLIPVisionEncode', inputs: { clip_vision: ['2', 1], image: ['1', 0], crop: 'none' } },
'4': { class_type: 'Hunyuan3Dv2Conditioning', inputs: { clip_vision_output: ['3', 0] } },
'5': { class_type: 'ModelSamplingAuraFlow', inputs: { model: ['2', 0], shift: 1.0 } },
'6': { class_type: 'EmptyLatentHunyuan3Dv2', inputs: { resolution: 512, batch_size: 1 } },
'7': { class_type: 'KSampler', inputs: {
model: ['5', 0], positive: ['4', 0], negative: ['4', 1], latent_image: ['6', 0],
seed: o.seed, steps: o.steps, cfg: 5.0, sampler_name: 'euler', scheduler: 'normal', denoise: 1.0 } },
'8': { class_type: 'VAEDecodeHunyuan3D', inputs: { vae: ['2', 2], samples: ['7', 0], num_chunks: 8000, octree_resolution: o.octree } },
'9': { class_type: 'VoxelToMesh', inputs: { voxel: ['8', 0], algorithm: 'surface net', threshold: 0.6 } },
'10': { class_type: 'DecimateMesh', inputs: { mesh: ['9', 0], target_face_count: o.faces, placement_mode: 'midpoint' } },
'11': { class_type: 'MeshSmoothNormals', inputs: { mesh: ['10', 0], crease_angle: 180.0 } },
'12': { class_type: 'SaveGLB', inputs: { mesh: ['11', 0], filename_prefix: o.prefix } },
'13': { class_type: 'RotateMesh', inputs: { mesh: ['11', 0], mode: 'euler_xyz', 'mode.angle_x': 0, 'mode.angle_y': 35, 'mode.angle_z': 0 } },
'14': { class_type: 'RenderMesh', inputs: { mesh: ['13', 0], mode: 'solid', width: 512, height: 512, background: '#E7E5E4' } },
'15': { class_type: 'SaveImage', inputs: { images: ['14', 0], filename_prefix: o.prefix + '_preview' } },
};
}
/** Что за задача в очереди — по нодам графа (работает и для задач, запущенных прямо в ComfyUI). */
export function kindOf(graph: unknown): JobKind {
const types = Object.values((graph ?? {}) as Graph).map(n => n?.class_type);
if (types.includes('SaveGLB')) return '3D-модель';
if (types.includes('RemoveBackground')) return 'очистка фона';
if (types.includes('KSampler') && types.includes('CheckpointLoaderSimple')) return 'генерация';
return 'другое';
}
/** То же, что Python str.format для {a}/{b}: {{ и }} превращаются в одиночные скобки. */
export function renderTemplate(template: string, values: { a: string; b: string }): string {
return template.replace(/\{\{|\}\}|\{([ab])\}/g, (m, k: 'a' | 'b' | undefined) => (k ? values[k] : m[0]));
}
/**
* Шаблон пользователя → f_string для StringFormat (Python str.format): все скобки, кроме {a} и {b}, удваиваются.
* Значения {a}/{b} format не разбирает — их не экранируем.
*/
export function escapeTemplate(template: string): string {
return template.split(/(\{[ab]\})/).map(p => /^\{[ab]\}$/.test(p) ? p : p.replace(/\{/g, '{{').replace(/\}/g, '}}')).join('');
}
/** Обратно к шаблону пользователя: {{ → {, }} → }. */
export function unescapeTemplate(fString: string): string {
return fString.replace(/\{\{/g, '{').replace(/\}\}/g, '}');
}
// ---------- разбор графа text2img (из метаданных PNG) обратно в пользовательский ввод ----------
type Link = [string, number];
const isLink = (v: unknown): v is Link => Array.isArray(v) && typeof v[0] === 'string';
/** Значение строкового входа: строка или ссылка на строковую ноду (Primitive, StringConcatenate, StringFormat). */
function resolveString(graph: Graph, v: unknown, depth = 0): string | null {
if (typeof v === 'string') return v;
if (!isLink(v) || depth > 20) return null;
const node = graph[v[0]];
if (!node) return null;
const i = node.inputs;
switch (node.class_type) {
case 'PrimitiveStringMultiline':
case 'PrimitiveString':
return resolveString(graph, i.value, depth + 1);
case 'StringConcatenate': {
const a = resolveString(graph, i.string_a, depth + 1);
const b = resolveString(graph, i.string_b, depth + 1);
return a == null || b == null ? null : a + String(i.delimiter ?? '') + b;
}
case 'StringFormat': {
const f = resolveString(graph, i.f_string, depth + 1);
if (f == null) return null;
return renderTemplate(f, { a: resolveString(graph, i['values.a'], depth + 1) ?? '', b: resolveString(graph, i['values.b'], depth + 1) ?? '' });
}
default:
return null;
}
}
/**
* Пользовательский ввод из графа text2img. Если промпт собран по шаблону (StringFormat) — форма, цвета и шаблон
* восстанавливаются как были; иначе итоговый текст промпта становится шаблоном без плейсхолдеров.
*/
export function inputFromGraph(graphJson: unknown): { input: Text2ImgInput; seed: number; prompt: string } | null {
const graph = graphJson as Graph;
if (!graph || typeof graph !== 'object') return null;
const ks = Object.values(graph).find(n => n?.class_type === 'KSampler');
if (!ks || !isLink(ks.inputs.positive) || !isLink(ks.inputs.negative)) return null;
const pos = graph[ks.inputs.positive[0]];
const neg = graph[ks.inputs.negative[0]];
if (pos?.class_type !== 'CLIPTextEncode') return null;
const prompt = resolveString(graph, pos.inputs.text);
if (prompt == null) return null;
const latent = isLink(ks.inputs.latent_image) ? graph[ks.inputs.latent_image[0]] : undefined;
const seed = Number(ks.inputs.seed);
const base = {
negative: (neg?.class_type === 'CLIPTextEncode' ? resolveString(graph, neg.inputs.text) : null) ?? '',
batch: Number(latent?.inputs.batch_size ?? 1),
seed: String(seed),
};
const textLink = pos.inputs.text;
const fmt = isLink(textLink) ? graph[textLink[0]] : undefined;
if (fmt?.class_type === 'StringFormat') {
return {
seed, prompt, input: {
...base,
template: unescapeTemplate(resolveString(graph, fmt.inputs.f_string) ?? ''),
shape: resolveString(graph, fmt.inputs['values.a']) ?? '',
colors: resolveString(graph, fmt.inputs['values.b']) ?? '',
},
};
}
return { seed, prompt, input: { ...base, template: prompt, shape: '', colors: '' } };
}