285 lines
19 KiB
TypeScript
285 lines
19 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 } },
|
||
...meshPreview(['11', 0], o.prefix, 13),
|
||
};
|
||
}
|
||
|
||
/** PNG-превью меша без текстур в ракурсе 3/4 → <prefix>_preview_…; узлы id, id+1, id+2. */
|
||
function meshPreview(mesh: Link, prefix: string, id: number): Graph {
|
||
return {
|
||
[id]: { class_type: 'RotateMesh', inputs: { mesh, mode: 'euler_xyz', 'mode.angle_x': 0, 'mode.angle_y': 35, 'mode.angle_z': 0 } },
|
||
[id + 1]: { class_type: 'RenderMesh', inputs: { mesh: [String(id), 0], mode: 'solid', width: 512, height: 512, background: '#E7E5E4' } },
|
||
[id + 2]: { class_type: 'SaveImage', inputs: { images: [String(id + 1), 0], filename_prefix: prefix + '_preview' } },
|
||
};
|
||
}
|
||
|
||
/** Ракурсы рендеров текстурированной модели TRELLIS.2, градусы поворота вокруг вертикали. */
|
||
export const TEXTURE_VIEWS = [0, 90, 180, 270];
|
||
|
||
/**
|
||
* Общая часть TRELLIS.2 (встроенные ноды ComfyUI, по шаблону 3d_pixal3d_trellis2_image_to_model): по картинке —
|
||
* плотный меш формы (нода 22) и цвет-воксели (нода 25).
|
||
*/
|
||
function trellisNodes(o: { image: string; seed: number }): Graph {
|
||
return {
|
||
// картинка → маска → кадр (TRELLIS.2: pad_factor 1.0) → условие DINOv3
|
||
'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: 1024, height: 1024, pad_factor: 1.0, grow_mask: 0, background: '#000000' } },
|
||
'5': { class_type: 'CLIPVisionLoader', inputs: { clip_name: 'dino_v3_L_naf_fp32.safetensors' } },
|
||
'6': { class_type: 'Trellis2Conditioning', inputs: { clip_vision_model: ['5', 0], image: ['4', 0] } },
|
||
'7': { class_type: 'UNETLoader', inputs: { unet_name: 'trellis_2_int8_convrot.safetensors', weight_dtype: 'default' } },
|
||
'8': { class_type: 'VAELoader', inputs: { vae_name: 'trellis_2_shape_vae_bf16.safetensors' } },
|
||
'9': { class_type: 'VAELoader', inputs: { vae_name: 'trellis_2_texture_vae_bf16.safetensors' } },
|
||
// структура (разреженные воксели)
|
||
'10': { class_type: 'CFGOverride', inputs: { model: ['7', 0], cfg: 1, start_percent: 0.667, end_percent: 1 } },
|
||
'11': { class_type: 'RescaleCFG', inputs: { model: ['10', 0], multiplier: 0.7 } },
|
||
'12': { class_type: 'ModelSamplingSD3', inputs: { model: ['11', 0], shift: 5 } },
|
||
'13': { class_type: 'EmptyTrellis2LatentStructure', inputs: { batch_size: 1 } },
|
||
'14': { class_type: 'KSampler', inputs: { model: ['12', 0], positive: ['6', 0], negative: ['6', 1], latent_image: ['13', 0],
|
||
seed: o.seed, steps: 12, cfg: 7.5, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
|
||
'15': { class_type: 'VaeDecodeStructureTrellis2', inputs: { samples: ['14', 0], vae: ['8', 0], resolution: '32' } },
|
||
// форма 512 → 1536
|
||
'16': { class_type: 'Trellis2ShapeStage', inputs: { positive: ['6', 0], negative: ['6', 1], voxel: ['15', 0] } },
|
||
'17': { class_type: 'CFGOverride', inputs: { model: ['7', 0], cfg: 1, start_percent: 0.769, end_percent: 1 } },
|
||
'18': { class_type: 'RescaleCFG', inputs: { model: ['17', 0], multiplier: 0.5 } },
|
||
'19': { class_type: 'KSampler', inputs: { model: ['18', 0], positive: ['16', 0], negative: ['16', 1], latent_image: ['16', 2],
|
||
seed: o.seed, steps: 20, cfg: 7.5, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
|
||
'20': { class_type: 'Trellis2UpsampleStage', inputs: { positive: ['16', 0], negative: ['16', 1], shape_latent: ['19', 0], vae: ['8', 0],
|
||
target_resolution: 1536 } },
|
||
'21': { class_type: 'KSampler', inputs: { model: ['18', 0], positive: ['20', 0], negative: ['20', 1], latent_image: ['20', 2],
|
||
seed: o.seed, steps: 12, cfg: 7.5, sampler_name: 'euler', scheduler: 'simple', denoise: 1 } },
|
||
'22': { class_type: 'VaeDecodeShapeTrellis', inputs: { samples: ['21', 0], vae: ['8', 0] } },
|
||
// цвет-воксели
|
||
'23': { class_type: 'Trellis2TextureStage', inputs: { positive: ['20', 0], negative: ['20', 1], shape_latent: ['21', 0] } },
|
||
'24': { class_type: 'KSampler', inputs: { model: ['7', 0], positive: ['23', 0], negative: ['23', 1], latent_image: ['23', 2],
|
||
seed: o.seed + 1, steps: 12, cfg: 1, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
|
||
'25': { class_type: 'VaeDecodeTextureTrellis', inputs: { samples: ['24', 0], vae: ['9', 0], shape_subdivides: ['22', 1] } },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Текстурированная модель (узел textured) и её файлы: GLB, base color, развёртка (узел unwrapped — меш с UV).
|
||
* Файлы: <prefix>_NNNNN_.glb, <prefix>_basecolor_…, <prefix>_uv_….
|
||
*/
|
||
function saveTextured(g: Graph, prefix: string, unwrapped: string, baseColor: Link, textured: Link) {
|
||
g['32'] = { class_type: 'MeshSmoothNormals', inputs: { mesh: textured, crease_angle: 180 } };
|
||
g['33'] = { class_type: 'SaveGLB', inputs: { mesh: ['32', 0], filename_prefix: prefix } };
|
||
g['34'] = { class_type: 'SaveImage', inputs: { images: baseColor, filename_prefix: prefix + '_basecolor' } };
|
||
g['35'] = { class_type: 'RenderUVAtlas', inputs: { mesh: [unwrapped, 0], resolution: 1024 } };
|
||
g['36'] = { class_type: 'SaveImage', inputs: { images: ['35', 0], filename_prefix: prefix + '_uv' } };
|
||
return g;
|
||
}
|
||
|
||
/**
|
||
* Модель TRELLIS.2 целиком: своя форма по картинке → ремеш и упрощение до o.faces треугольников (это и модель
|
||
* без текстур, <prefix>_mesh_…glb + превью) → UV-развёртка → текстуры из цвет-вокселей: base color и, если o.pbr,
|
||
* metallic и roughness → текстурированный GLB, развёртка и рендеры с 4 сторон (<prefix>_view<угол>_…).
|
||
*/
|
||
export function trellisGraph(o: {
|
||
image: string; prefix: string; seed: number; faces: number; textureSize: number; pbr: boolean;
|
||
}): Graph {
|
||
const g = trellisNodes(o);
|
||
g['26'] = { class_type: 'RemeshMesh', inputs: { mesh: ['22', 0], resolution: 768, sign_mode: 'udf', 'sign_mode.qef': false,
|
||
'sign_mode.drop_inverted_components': false, 'sign_mode.drop_enclosed_components': false, band: 1, project_back: 0,
|
||
fix_poles: false, smooth_iters: 20, drop_small_components: 0.01, precluster_max_verts: 20000000 } };
|
||
g['27'] = { class_type: 'DecimateMesh', inputs: { mesh: ['26', 0], target_face_count: o.faces, placement_mode: 'midpoint' } };
|
||
// модель без текстур
|
||
g['38'] = { class_type: 'MeshSmoothNormals', inputs: { mesh: ['27', 0], crease_angle: 180 } };
|
||
g['39'] = { class_type: 'SaveGLB', inputs: { mesh: ['38', 0], filename_prefix: o.prefix + '_mesh' } };
|
||
Object.assign(g, meshPreview(['38', 0], o.prefix, 60));
|
||
// текстуры: каждый тексель проецируется на плотную поверхность TRELLIS.2 и берёт цвет из её цвет-вокселей
|
||
g['28'] = { class_type: 'UnwrapMesh', inputs: { mesh: ['27', 0], segmenter: 'pec', resolution: o.textureSize, padding: 1, weld_distance: 0.0002 } };
|
||
g['29'] = { class_type: 'BakeTextureFromVoxel', inputs: { mesh: ['28', 0], voxel_colors: ['25', 0], texture_size: o.textureSize,
|
||
reference_mesh: ['22', 0] } };
|
||
g['30'] = { class_type: 'ApplyTextureToMesh', inputs: {
|
||
mesh: ['28', 0], base_color: ['29', 0], ...(o.pbr ? { metallic: ['29', 1], roughness: ['29', 2] } : {}) } };
|
||
saveTextured(g, o.prefix, '28', ['29', 0], ['30', 0]);
|
||
if (o.pbr) {
|
||
g['52'] = { class_type: 'SaveImage', inputs: { images: ['29', 1], filename_prefix: o.prefix + '_metallic' } };
|
||
g['53'] = { class_type: 'SaveImage', inputs: { images: ['29', 2], filename_prefix: o.prefix + '_roughness' } };
|
||
}
|
||
TEXTURE_VIEWS.forEach((angle, i) => {
|
||
const r = String(40 + i * 3), v = String(41 + i * 3), s = String(42 + i * 3);
|
||
g[r] = { class_type: 'RotateMesh', inputs: { mesh: ['32', 0], mode: 'euler_xyz', 'mode.angle_x': 0, 'mode.angle_y': angle, 'mode.angle_z': 0 } };
|
||
g[v] = { class_type: 'RenderMesh', inputs: { mesh: [r, 0], mode: 'texture', width: 512, height: 512, background: '#E7E5E4' } };
|
||
g[s] = { class_type: 'SaveImage', inputs: { images: [v, 0], filename_prefix: `${o.prefix}_view${angle}` } };
|
||
});
|
||
return g;
|
||
}
|
||
|
||
/**
|
||
* Текстура Hunyuan3D-2 Paint (turbo) для модели проекта — нода custom_nodes/img2glb_hunyuan_paint:
|
||
* Delight снимает свет с очищенной картинки, мультивью-модель рисует 6 видов (<prefix>_mv_…) по нормалям и
|
||
* позициям меша (o.mesh — путь в output) после UV-развёртки, виды запекаются в текстуру. Геометрия не меняется.
|
||
*/
|
||
export function paintGraph(o: { image: string; mesh: string; prefix: string; seed: number; textureSize: number; delight: boolean }): Graph {
|
||
const g: Graph = {
|
||
'1': { class_type: 'LoadImage', inputs: { image: o.image } },
|
||
// маска объекта: фон картинки заменяется белым, как в оригинальном пайплайне (там картинка RGBA)
|
||
'2': { class_type: 'LoadBackgroundRemovalModel', inputs: { bg_removal_name: 'BiRefNet-general.safetensors' } },
|
||
'3': { class_type: 'RemoveBackground', inputs: { bg_removal_model: ['2', 0], image: ['1', 0] } },
|
||
'26': { class_type: 'Img2GlbLoadOutputMesh', inputs: { path: o.mesh } },
|
||
'28': { class_type: 'UnwrapMesh', inputs: { mesh: ['26', 0], segmenter: 'pec', resolution: o.textureSize, padding: 1, weld_distance: 0.0002 } },
|
||
'29': { class_type: 'Img2GlbHunyuanPaint', inputs: { mesh: ['28', 0], image: ['1', 0], mask: ['3', 0],
|
||
texture_size: o.textureSize, seed: o.seed, delight: o.delight } },
|
||
'30': { class_type: 'ApplyTextureToMesh', inputs: { mesh: ['28', 0], base_color: ['29', 0] } },
|
||
'37': { class_type: 'SaveImage', inputs: { images: ['29', 1], filename_prefix: o.prefix + '_mv' } },
|
||
};
|
||
return saveTextured(g, o.prefix, '28', ['29', 0], ['30', 0]);
|
||
}
|
||
|
||
/** Что за задача в очереди — по нодам графа (работает и для задач, запущенных прямо в ComfyUI). */
|
||
export function kindOf(graph: unknown): JobKind {
|
||
const types = Object.values((graph ?? {}) as Graph).map(n => n?.class_type);
|
||
if (types.includes('Img2GlbHunyuanPaint')) return 'текстура';
|
||
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: '' } };
|
||
}
|