Files
sprite-ai/server/workflows.ts
T
2026-09-30 07:38:21 +03:00

331 lines
23 KiB
TypeScript
Raw 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.
// Графы 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' } },
};
}
/**
* Очищенная картинка → GLB через ноды ComfyUI-Hunyuan3DWrapper (kijai) на той же модели Hunyuan3D-2 mini:
* Hy3DModelLoader → Hy3DGenerateMesh (объект на белом, 518×518) → Hy3DVAEDecode → Hy3DPostprocessMesh (до o.faces) →
* [Hy3DIMRemesh → Img2GlbRepairTrimesh → упрощение, если o.remeshVertices > 0] → Hy3DExportMesh. Файл сохраняет Hy3DExportMesh, в историю задачи его
* записывает Img2GlbOutputFile; превью — встроенными нодами по тому же файлу.
*/
export function hy3dWrapperGraph(o: {
image: string; prefix: string; seed: number; steps: number; octree: number; faces: number; remeshVertices: number;
}): Graph {
const g: Graph = {
'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] } },
// объект по маске — в квадрат 518 (вход DINO) с полями на белом фоне, как в обучении Hunyuan3D; маска в
// Hy3DGenerateMesh не подаётся — она лишь умножается на картинку, и фон становится чёрным
'15': { class_type: 'ImageCropToMask', inputs: {
images: ['1', 0], masks: ['3', 0], width: 518, height: 518, pad_factor: 1.2, grow_mask: 0, background: '#FFFFFF' } },
'4': { class_type: 'Hy3DModelLoader', inputs: { model: 'hunyuan3d-dit-v2-mini_fp16.safetensors', attention_mode: 'sdpa', cublas_ops: false } },
'5': { class_type: 'Hy3DGenerateMesh', inputs: { pipeline: ['4', 0], image: ['15', 0], guidance_scale: 5.5, steps: o.steps,
seed: o.seed, scheduler: 'FlowMatchEulerDiscreteScheduler', force_offload: true } },
'6': { class_type: 'Hy3DVAEDecode', inputs: { vae: ['4', 1], latents: ['5', 0], box_v: 1.01, octree_resolution: o.octree, num_chunks: 8000,
mc_level: 0, mc_algo: 'mc', enable_flash_vdm: true, force_offload: true } },
'7': { class_type: 'Hy3DPostprocessMesh', inputs: { trimesh: ['6', 0], remove_floaters: true, remove_degenerate_faces: true,
reduce_faces: true, max_facenum: o.faces, smooth_normals: false } },
};
let mesh: Link = ['7', 0];
if (o.remeshVertices > 0) {
// Instant Meshes оставляет дыры в случайных местах и неманифолдные рёбра: перестраиваем без упрощения,
// чиним (Img2GlbRepairTrimesh) и только потом упрощаем до o.faces
g['7'].inputs.max_facenum = Math.max(o.faces, 20000);
g['8'] = { class_type: 'Hy3DIMRemesh', inputs: { trimesh: mesh, merge_vertices: true, vertex_count: o.remeshVertices, smooth_iter: 8,
align_to_boundaries: true, triangulate_result: true, max_facenum: 10_000_000 } };
g['16'] = { class_type: 'Img2GlbRepairTrimesh', inputs: { trimesh: ['8', 0] } };
g['17'] = { class_type: 'Hy3DPostprocessMesh', inputs: { trimesh: ['16', 0], remove_floaters: false, remove_degenerate_faces: true,
reduce_faces: true, max_facenum: o.faces, smooth_normals: false } };
mesh = ['17', 0];
}
Object.assign(g, {
'9': { class_type: 'Hy3DExportMesh', inputs: { trimesh: mesh, filename_prefix: o.prefix, file_format: 'glb', save_file: true } },
'10': { class_type: 'Img2GlbOutputFile', inputs: { path: ['9', 0] } },
'11': { class_type: 'Img2GlbLoadOutputMesh', inputs: { path: ['9', 0] } },
...meshPreview(['11', 0], o.prefix, 12),
});
return g;
}
/** Ракурсы рендеров текстурированной модели 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') || types.includes('Hy3DExportMesh')) 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: '' } };
}