333 lines
19 KiB
TypeScript
333 lines
19 KiB
TypeScript
// Сведения о GLB-файле из его JSON-чанка (без загрузки геометрии).
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import type { FileRef } from '../shared/api.ts';
|
|
import { retargetAnimations } from './anim.ts';
|
|
import { COMFY_OUTPUT } from './config.ts';
|
|
|
|
interface Gltf {
|
|
meshes?: { primitives?: { indices?: number; mode?: number; attributes?: { POSITION?: number } }[] }[];
|
|
accessors?: { count?: number }[];
|
|
}
|
|
|
|
/** Путь к файлу в ComfyUI/output или null (не output или выход за папку). */
|
|
export function outputPath(ref: FileRef): string | null {
|
|
if (ref.type !== 'output') return null;
|
|
const file = path.resolve(COMFY_OUTPUT, ref.subfolder, ref.filename);
|
|
return file.startsWith(path.resolve(COMFY_OUTPUT) + path.sep) ? file : null;
|
|
}
|
|
|
|
/**
|
|
* Тот же GLB без нормалей — для просмотра «без сглаживания»: по спецификации glTF при отсутствии NORMAL
|
|
* просмотрщик считает плоские нормали (three.js в model-viewer включает flatShading). Геометрия не меняется,
|
|
* переписывается только JSON-чанк.
|
|
*/
|
|
export function glbWithoutNormals(glb: Buffer): Buffer {
|
|
if (glb.toString('latin1', 0, 4) !== 'glTF' || glb.toString('latin1', 16, 20) !== 'JSON') throw new Error('не GLB');
|
|
const jsonLen = glb.readUInt32LE(12);
|
|
const gltf = JSON.parse(glb.toString('utf8', 20, 20 + jsonLen)) as Gltf;
|
|
for (const mesh of gltf.meshes ?? []) for (const prim of mesh.primitives ?? []) delete (prim.attributes as Record<string, unknown>)?.NORMAL;
|
|
let json = Buffer.from(JSON.stringify(gltf), 'utf8');
|
|
if (json.length % 4) json = Buffer.concat([json, Buffer.alloc(4 - (json.length % 4), 0x20)]); // выравнивание пробелами
|
|
const rest = glb.subarray(20 + jsonLen); // BIN-чанк как есть
|
|
const head = Buffer.alloc(20);
|
|
head.write('glTF', 0, 'latin1');
|
|
head.writeUInt32LE(2, 4);
|
|
head.writeUInt32LE(20 + json.length + rest.length, 8);
|
|
head.writeUInt32LE(json.length, 12);
|
|
head.write('JSON', 16, 'latin1');
|
|
return Buffer.concat([head, json, rest]);
|
|
}
|
|
|
|
/** Число треугольников в GLB из ComfyUI/output (или null, если файла нет или формат не тот). */
|
|
export function glbTriangles(ref: FileRef): number | null {
|
|
const file = outputPath(ref);
|
|
if (!file) return null;
|
|
let fd: number | undefined;
|
|
try {
|
|
fd = fs.openSync(file, 'r');
|
|
// заголовок GLB (12 байт) + заголовок первого чанка (8 байт): длина и тип 'JSON'
|
|
const head = Buffer.alloc(20);
|
|
fs.readSync(fd, head, 0, 20, 0);
|
|
if (head.toString('latin1', 0, 4) !== 'glTF' || head.toString('latin1', 16, 20) !== 'JSON') return null;
|
|
const json = Buffer.alloc(head.readUInt32LE(12));
|
|
fs.readSync(fd, json, 0, json.length, 20);
|
|
const gltf = JSON.parse(json.toString('utf8')) as Gltf;
|
|
let triangles = 0;
|
|
for (const mesh of gltf.meshes ?? []) {
|
|
for (const prim of mesh.primitives ?? []) {
|
|
if ((prim.mode ?? 4) !== 4) continue; // только TRIANGLES
|
|
const acc = prim.indices ?? prim.attributes?.POSITION;
|
|
triangles += Math.floor((gltf.accessors?.[acc ?? -1]?.count ?? 0) / 3);
|
|
}
|
|
}
|
|
return triangles;
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
if (fd !== undefined) fs.closeSync(fd);
|
|
}
|
|
}
|
|
|
|
// ---------- перенос скелета и весов на исходный GLB ----------
|
|
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
type Json = Record<string, any>;
|
|
|
|
function readGlb(glb: Buffer): { json: Json; bin: Buffer } {
|
|
if (glb.toString('latin1', 0, 4) !== 'glTF' || glb.toString('latin1', 16, 20) !== 'JSON') throw new Error('не GLB');
|
|
const jsonLen = glb.readUInt32LE(12);
|
|
const json = JSON.parse(glb.toString('utf8', 20, 20 + jsonLen)) as Json;
|
|
const binStart = 20 + jsonLen;
|
|
const bin = binStart + 8 <= glb.length && glb.toString('latin1', binStart + 4, binStart + 8) === 'BIN\0'
|
|
? glb.subarray(binStart + 8, binStart + 8 + glb.readUInt32LE(binStart)) : Buffer.alloc(0);
|
|
return { json, bin };
|
|
}
|
|
|
|
const pad4 = (b: Buffer, fill: number) => (b.length % 4 ? Buffer.concat([b, Buffer.alloc(4 - (b.length % 4), fill)]) : b);
|
|
|
|
function writeGlb(json: Json, bin: Buffer): Buffer {
|
|
const j = pad4(Buffer.from(JSON.stringify(json), 'utf8'), 0x20);
|
|
const b = pad4(bin, 0);
|
|
const head = Buffer.alloc(12);
|
|
head.write('glTF', 0, 'latin1');
|
|
head.writeUInt32LE(2, 4);
|
|
head.writeUInt32LE(12 + 8 + j.length + 8 + b.length, 8);
|
|
const chunk = (len: number, type: string) => {
|
|
const h = Buffer.alloc(8);
|
|
h.writeUInt32LE(len, 0);
|
|
h.write(type, 4, 'latin1');
|
|
return h;
|
|
};
|
|
return Buffer.concat([head, chunk(j.length, 'JSON'), j, chunk(b.length, 'BIN\0'), b]);
|
|
}
|
|
|
|
/**
|
|
* Скелет и веса скиннинга из GLB SkinTokens (skin-tokens.cpp пишет только позиции, нормали и цвета) — на исходный
|
|
* GLB с его UV, текстурами и материалом. SkinTokens сохраняет порядок и число вершин входа, поэтому JOINTS_0 и
|
|
* WEIGHTS_0 переносятся вершина в вершину; это проверяется. motionGlb — исходный скелет пользователя: его анимации
|
|
* пересчитываются под позу покоя рига (anim.ts), т.к. skin-tokens.cpp копирует ключи без пересчёта; без него
|
|
* анимации суставов из результата переносятся как есть.
|
|
*/
|
|
export function graftSkin(baseGlb: Buffer, riggedGlb: Buffer, motionGlb?: Buffer,
|
|
named?: { glb: Buffer; name: string }[]): Buffer {
|
|
const base = readGlb(baseGlb);
|
|
const rig = readGlb(riggedGlb);
|
|
const bj = base.json, rj = rig.json;
|
|
const skin = rj.skins?.[0];
|
|
const rigPrim = rj.meshes?.[0]?.primitives?.[0];
|
|
if (!skin || !rigPrim?.attributes?.JOINTS_0 || !rigPrim?.attributes?.WEIGHTS_0) throw new Error('в результате SkinTokens нет скиннинга');
|
|
if ((bj.meshes?.length ?? 0) !== 1 || bj.meshes[0].primitives.length !== 1) throw new Error('исходный GLB должен содержать один меш из одного примитива');
|
|
const basePrim = bj.meshes[0].primitives[0];
|
|
const count = bj.accessors[basePrim.attributes.POSITION].count;
|
|
if (rj.accessors[rigPrim.attributes.POSITION].count !== count) {
|
|
throw new Error(`SkinTokens изменил число вершин (${rj.accessors[rigPrim.attributes.POSITION].count} вместо ${count})`);
|
|
}
|
|
|
|
// данные аксессоров SkinTokens дописываются в буфер исходного GLB
|
|
const chunks: Buffer[] = [pad4(base.bin, 0)];
|
|
let offset = chunks[0].length;
|
|
const copyAccessor = (index: number, src: { json: Json; bin: Buffer } = rig): number => {
|
|
const acc = src.json.accessors[index];
|
|
const view = src.json.bufferViews[acc.bufferView];
|
|
const data = pad4(Buffer.from(src.bin.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength)), 0);
|
|
bj.bufferViews.push({ buffer: 0, byteOffset: offset, byteLength: view.byteLength, ...(view.byteStride ? { byteStride: view.byteStride } : {}) });
|
|
chunks.push(data);
|
|
offset += data.length;
|
|
bj.accessors.push({ ...acc, bufferView: bj.bufferViews.length - 1 });
|
|
return bj.accessors.length - 1;
|
|
};
|
|
basePrim.attributes.JOINTS_0 = copyAccessor(rigPrim.attributes.JOINTS_0);
|
|
basePrim.attributes.WEIGHTS_0 = copyAccessor(rigPrim.attributes.WEIGHTS_0);
|
|
const ibm = copyAccessor(skin.inverseBindMatrices);
|
|
|
|
// узлы скелета: индексы сдвигаются на число узлов исходного GLB
|
|
const shift = bj.nodes.length;
|
|
const jointSet = new Set<number>(skin.joints);
|
|
for (const j of skin.joints as number[]) {
|
|
const node = { ...rj.nodes[j] };
|
|
if (node.children) node.children = node.children.map((c: number) => c + shift);
|
|
bj.nodes[shift + j] = node;
|
|
}
|
|
// пропуски (узел меша SkinTokens) не нужны: переносятся только суставы, их индексы должны идти подряд
|
|
if (bj.nodes.length !== shift + Math.max(...skin.joints) + 1 || [...jointSet].length !== skin.joints.length) {
|
|
throw new Error('неожиданная нумерация суставов SkinTokens');
|
|
}
|
|
bj.skins = [{ name: skin.name, joints: skin.joints.map((j: number) => j + shift), inverseBindMatrices: ibm,
|
|
...(skin.skeleton !== undefined ? { skeleton: skin.skeleton + shift } : {}) }];
|
|
const meshNode = bj.nodes.findIndex((n: Json) => n.mesh === 0);
|
|
bj.nodes[meshNode].skin = 0;
|
|
const roots = (skin.joints as number[]).filter(j => !skin.joints.some((p: number) => rj.nodes[p].children?.includes(j)));
|
|
bj.scenes[bj.scene ?? 0].nodes.push(...roots.map(r => r + shift));
|
|
|
|
const addAccessor = (values: number[], type: 'SCALAR' | 'VEC3' | 'VEC4', minMax = false): number => {
|
|
const data = Buffer.from(Float32Array.from(values).buffer);
|
|
bj.bufferViews.push({ buffer: 0, byteOffset: offset, byteLength: data.length });
|
|
chunks.push(pad4(data, 0));
|
|
offset += pad4(data, 0).length;
|
|
const n = { SCALAR: 1, VEC3: 3, VEC4: 4 }[type];
|
|
bj.accessors.push({ bufferView: bj.bufferViews.length - 1, componentType: 5126, count: values.length / n, type,
|
|
...(minMax ? { min: [Math.min(...values)], max: [Math.max(...values)] } : {}) });
|
|
return bj.accessors.length - 1;
|
|
};
|
|
|
|
// анимации: пересчёт из исходного скелета; либо из файлов ретаргета (named — тот же скелет, по анимации на файл,
|
|
// под своим именем); либо каналы суставов из результата как есть
|
|
const animations = motionGlb ? retargetAnimations(readGlb(motionGlb), rj, skin.joints, addAccessor, shift) : [];
|
|
const sources = motionGlb ? [] : named
|
|
? named.map(n => ({ ...readGlb(n.glb), name: n.name as string | undefined }))
|
|
: [{ ...rig, name: undefined as string | undefined }];
|
|
for (const src of sources) {
|
|
const srcSkin = src.json.skins?.[0];
|
|
if (src !== sources[0] || named) {
|
|
const same = srcSkin && srcSkin.joints.length === skin.joints.length
|
|
&& (srcSkin.joints as number[]).every((j, k) => j === skin.joints[k] && src.json.nodes[j].name === rj.nodes[j].name);
|
|
if (!same) throw new Error('анимация сделана для другого скелета');
|
|
}
|
|
for (const anim of (src.json.animations ?? []) as Json[]) {
|
|
const samplers: Json[] = [];
|
|
const remap = new Map<number, number>();
|
|
const channels = [];
|
|
for (const ch of anim.channels as Json[]) {
|
|
if (!jointSet.has(ch.target?.node)) continue;
|
|
if (!remap.has(ch.sampler)) {
|
|
const smp = anim.samplers[ch.sampler];
|
|
remap.set(ch.sampler, samplers.length);
|
|
samplers.push({ ...smp, input: copyAccessor(smp.input, src), output: copyAccessor(smp.output, src) });
|
|
}
|
|
channels.push({ sampler: remap.get(ch.sampler), target: { ...ch.target, node: ch.target.node + shift } });
|
|
}
|
|
const name = src.name ?? anim.name;
|
|
if (channels.length) animations.push({ ...(name ? { name } : {}), channels, samplers });
|
|
}
|
|
}
|
|
if (animations.length) bj.animations = animations;
|
|
|
|
const bin = Buffer.concat(chunks);
|
|
bj.buffers = [{ byteLength: bin.length }];
|
|
return writeGlb(bj, bin);
|
|
}
|
|
|
|
/** Пересчитать анимации уже готового рига (graftSkin) из исходного скелета — без повторного запуска SkinTokens. */
|
|
export function retargetRig(rigGlb: Buffer, motionGlb: Buffer): Buffer {
|
|
const { json, bin } = readGlb(rigGlb);
|
|
const chunks: Buffer[] = [pad4(bin, 0)];
|
|
let offset = chunks[0].length;
|
|
const addAccessor = (values: number[], type: 'SCALAR' | 'VEC3' | 'VEC4', minMax = false): number => {
|
|
const data = Buffer.from(Float32Array.from(values).buffer);
|
|
json.bufferViews.push({ buffer: 0, byteOffset: offset, byteLength: data.length });
|
|
chunks.push(pad4(data, 0));
|
|
offset += pad4(data, 0).length;
|
|
const n = { SCALAR: 1, VEC3: 3, VEC4: 4 }[type];
|
|
json.accessors.push({ bufferView: json.bufferViews.length - 1, componentType: 5126, count: values.length / n, type,
|
|
...(minMax ? { min: [Math.min(...values)], max: [Math.max(...values)] } : {}) });
|
|
return json.accessors.length - 1;
|
|
};
|
|
const animations = retargetAnimations(readGlb(motionGlb), json, json.skins[0].joints, addAccessor, 0);
|
|
if (animations.length) json.animations = animations; else delete json.animations;
|
|
const out = Buffer.concat(chunks);
|
|
json.buffers = [{ byteLength: out.length }];
|
|
return writeGlb(json, out);
|
|
}
|
|
|
|
/**
|
|
* Скелет SkinTokens не всегда в той топологии, которую принимает retarget-generated (грудь → две цепочки
|
|
* ключица/плечо/предплечье/кисть + шея; корень → позвоночник + две ноги бедро/голень/стопа/носок). Бывает:
|
|
* - руки прицеплены к шее, а «ключицы» — пустые листовые суставы у груди (или их нет вовсе) → плечи перевешиваются
|
|
* на ключицы (недостающие ключицы добавляются в точке шеи);
|
|
* - нога из 3 суставов, без носка → добавляется носок перед стопой.
|
|
* Мировые позиции прежних суставов и их обратные матрицы привязки не меняются, у новых суставов нет весов — скиннинг
|
|
* тот же. Возвращает null, если исправлять нечего.
|
|
*/
|
|
export function normalizeHumanoid(rigGlb: Buffer): { glb: Buffer; changes: string[] } | null {
|
|
const { json, bin } = readGlb(rigGlb);
|
|
const nodes: Json[] = json.nodes ?? [];
|
|
const skin: Json | undefined = json.skins?.[0];
|
|
if (!skin || skin.inverseBindMatrices === undefined) return null;
|
|
const joints: number[] = skin.joints;
|
|
if (joints.some(j => nodes[j].rotation || nodes[j].scale || nodes[j].matrix)) return null;
|
|
// обратные матрицы привязки (column-major 4×4) — для добавляемых суставов: IBM = T(−смещение)·IBM родителя
|
|
const ibmAcc = json.accessors[skin.inverseBindMatrices];
|
|
const ibmView = json.bufferViews[ibmAcc.bufferView];
|
|
const ibmAt = (ibmView.byteOffset ?? 0) + (ibmAcc.byteOffset ?? 0);
|
|
const ibm: number[][] = joints.map((_, k) =>
|
|
Array.from({ length: 16 }, (_, e) => bin.readFloatLE(ibmAt + (k * 16 + e) * 4)));
|
|
const isJoint = new Set(joints);
|
|
const parent = new Map<number, number>();
|
|
nodes.forEach((n, i) => (n.children ?? []).forEach((c: number) => parent.set(c, i)));
|
|
const kids = (i: number): number[] => (nodes[i].children ?? []).filter((c: number) => isJoint.has(c));
|
|
const world = (i: number): number[] => {
|
|
const t: number[] = nodes[i].translation ?? [0, 0, 0];
|
|
const p = parent.get(i);
|
|
return p !== undefined && isJoint.has(p) ? world(p).map((v, k) => v + t[k]) : t.slice();
|
|
};
|
|
const dist = (a: number[], b: number[]) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
const reparent = (node: number, to: number) => {
|
|
const w = world(node), wt = world(to), from = parent.get(node)!;
|
|
nodes[from].children = nodes[from].children.filter((c: number) => c !== node);
|
|
nodes[to].children = [...(nodes[to].children ?? []), node];
|
|
parent.set(node, to);
|
|
nodes[node].translation = w.map((v, k) => v - wt[k]);
|
|
};
|
|
const addJoint = (under: number, offset: number[], name: string): number => {
|
|
const m = ibm[joints.indexOf(under)].slice();
|
|
for (let r = 0; r < 3; r++) for (let c = 0; c < 4; c++) m[c * 4 + r] -= offset[r] * m[c * 4 + 3];
|
|
const n = nodes.push({ name, translation: offset }) - 1;
|
|
nodes[under].children = [...(nodes[under].children ?? []), n];
|
|
parent.set(n, under);
|
|
joints.push(n); isJoint.add(n); ibm.push(m);
|
|
return n;
|
|
};
|
|
const root = joints.find(j => !isJoint.has(parent.get(j) ?? -1));
|
|
if (root === undefined) return null;
|
|
const changes: string[] = [];
|
|
|
|
// руки на шее
|
|
const armChain = (x: number) => kids(x).length === 1 && kids(kids(x)[0]).length === 1;
|
|
for (const neck of [...joints]) {
|
|
const chest = parent.get(neck);
|
|
if (chest === undefined || !isJoint.has(chest) || kids(neck).length !== 3) continue;
|
|
const wn = world(neck);
|
|
const scale = Math.max(dist(wn, world(root)), 1e-6);
|
|
const arms = kids(neck).filter(x => armChain(x) && Math.abs(world(x)[0] - wn[0]) > 0.1 * scale);
|
|
if (arms.length !== 2) continue;
|
|
const stubs = kids(chest).filter(l => l !== neck && !kids(l).length && dist(world(l), wn) < 0.1 * scale);
|
|
if (stubs.length !== 2 && kids(chest).length !== 1) continue;
|
|
const wc = world(chest);
|
|
const shoulders = stubs.length === 2 ? stubs
|
|
: [0, 1].map(k => addJoint(chest, wn.map((v, e) => v - wc[e]), `img2glb_shoulder_${k}`));
|
|
// по стороне (X); при совпадающих позициях ключиц — по порядку
|
|
const byX = (p: number, q: number) => world(p)[0] - world(q)[0];
|
|
const [a, b] = [...arms].sort(byX), [s, t] = [...shoulders].sort(byX);
|
|
for (const [arm, sh] of [[a, s], [b, t]]) {
|
|
reparent(arm, sh);
|
|
changes.push(`${nodes[arm].name ?? arm} → ${nodes[sh].name ?? sh}`);
|
|
}
|
|
break;
|
|
}
|
|
|
|
// ноги без носка: цепочка из 3 суставов от корня, уходящая вниз
|
|
for (const hip of kids(root)) {
|
|
const chain = [hip];
|
|
while (chain.length < 4 && kids(chain.at(-1)!).length === 1) chain.push(kids(chain.at(-1)!)[0]);
|
|
if (chain.length !== 3 || kids(chain[2]).length) continue;
|
|
const [wh, wk, wa] = chain.map(world);
|
|
if (!(wk[1] < wh[1] && wa[1] < wk[1])) continue;
|
|
const shin = dist(wk, wa);
|
|
// вперёд — +Z (SkinTokens: персонаж смотрит на +Z), чуть вниз
|
|
const toe = addJoint(chain[2], [0, -0.1 * shin, 0.33 * shin], `img2glb_toe_${nodes[chain[2]].name ?? chain[2]}`);
|
|
changes.push(`+ ${nodes[toe].name}`);
|
|
}
|
|
if (!changes.length) return null;
|
|
|
|
// новые обратные матрицы — отдельным bufferView в конце буфера
|
|
const data = Buffer.from(Float32Array.from(ibm.flat()).buffer);
|
|
const head = pad4(bin, 0);
|
|
json.bufferViews.push({ buffer: 0, byteOffset: head.length, byteLength: data.length });
|
|
json.accessors.push({ bufferView: json.bufferViews.length - 1, componentType: 5126, count: joints.length, type: 'MAT4' });
|
|
skin.inverseBindMatrices = json.accessors.length - 1;
|
|
const out = Buffer.concat([head, data]);
|
|
json.buffers = [{ ...json.buffers[0], byteLength: out.length }];
|
|
return { glb: writeGlb(json, out), changes };
|
|
}
|