skin-tokens

This commit is contained in:
Aggtaa
2026-09-30 07:38:21 +03:00
parent 8db94e5c31
commit 9cad7452bb
16 changed files with 1314 additions and 78 deletions
+69 -3
View File
@@ -9,12 +9,14 @@
"@google/model-viewer": "^4.1.0", "@google/model-viewer": "^4.1.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router": "^7.9.0" "react-router": "^7.9.0",
"three": "^0.183.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.18.0", "@types/node": "^22.18.0",
"@types/react": "^19.1.0", "@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.9.0", "typescript": "~5.9.0",
"vite": "^7.1.0" "vite": "^7.1.0"
@@ -302,6 +304,13 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2", "version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -1211,6 +1220,13 @@
"win32" "win32"
] ]
}, },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1293,12 +1309,42 @@
"@types/react": "^19.3.0" "@types/react": "^19.3.0"
} }
}, },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.183.1",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
"integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": ">=0.5.17",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~1.0.1"
}
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@@ -1320,6 +1366,13 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/@webgpu/types": {
"version": "0.1.74",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.74.tgz",
"integrity": "sha512-lgiI4hbuLcI9unnm2cL/tvCaQU45dp0xcLWJh5uB/9MBGvIA4F8XIk7nSiBeg+K4xWGYwgZKn80LmERI5CxoTA==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.25", "version": "2.11.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz",
@@ -1510,6 +1563,13 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"dev": true,
"license": "MIT"
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1630,6 +1690,13 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/meshoptimizer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
"integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
"dev": true,
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1860,8 +1927,7 @@
"version": "0.183.2", "version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
+3 -1
View File
@@ -12,12 +12,14 @@
"@google/model-viewer": "^4.1.0", "@google/model-viewer": "^4.1.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router": "^7.9.0" "react-router": "^7.9.0",
"three": "^0.183.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.18.0", "@types/node": "^22.18.0",
"@types/react": "^19.1.0", "@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.9.0", "typescript": "~5.9.0",
"vite": "^7.1.0" "vite": "^7.1.0"
+230
View File
@@ -0,0 +1,230 @@
// Перенос анимации скелета пользователя на риг SkinTokens.
// skin-tokens.cpp переводит позу покоя в мировые оси (повороты костей — единичные, переводы — в метрах, без
// промежуточных узлов вроде Armature с масштабом 0.01 из Blender), а ключи анимации копирует из исходника как есть —
// из-за этого они не сходятся с новой позой покоя. Здесь анимация пересчитывается: для каждой кости и кадра берётся её
// мировое движение относительно покоя в исходнике D = W(кадр)·W(покой)⁻¹ (весь путь от корня сцены, со всеми
// узлами), переводится в координаты рига (равномерный масштаб и сдвиг, найденные по положениям суставов) и
// прикладывается к позе покоя рига. Скиннинг W·IBM = D — меш двигается ровно как исходный.
/* eslint-disable @typescript-eslint/no-explicit-any */
type Json = Record<string, any>;
type M4 = Float64Array; // 4×4, по столбцам (как в glTF)
type V3 = [number, number, number];
type Q = [number, number, number, number]; // x, y, z, w
const ident = (): M4 => { const m = new Float64Array(16); m[0] = m[5] = m[10] = m[15] = 1; return m; };
function mul(a: M4, b: M4): M4 {
const r = new Float64Array(16);
for (let c = 0; c < 4; c++) for (let row = 0; row < 4; row++) {
let s = 0;
for (let k = 0; k < 4; k++) s += a[k * 4 + row] * b[c * 4 + k];
r[c * 4 + row] = s;
}
return r;
}
function compose(t: V3, q: Q, s: V3): M4 {
const [x, y, z, w] = q;
const m = new Float64Array(16);
m[0] = (1 - 2 * (y * y + z * z)) * s[0]; m[1] = 2 * (x * y + z * w) * s[0]; m[2] = 2 * (x * z - y * w) * s[0];
m[4] = 2 * (x * y - z * w) * s[1]; m[5] = (1 - 2 * (x * x + z * z)) * s[1]; m[6] = 2 * (y * z + x * w) * s[1];
m[8] = 2 * (x * z + y * w) * s[2]; m[9] = 2 * (y * z - x * w) * s[2]; m[10] = (1 - 2 * (x * x + y * y)) * s[2];
m[12] = t[0]; m[13] = t[1]; m[14] = t[2]; m[15] = 1;
return m;
}
/** Обратная для аффинной матрицы общего вида (3×3 + перенос). */
function inv(m: M4): M4 {
const [a, b, c, d, e, f, g, h, i] = [m[0], m[4], m[8], m[1], m[5], m[9], m[2], m[6], m[10]];
const A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g;
const det = a * A + b * B + c * C;
const r = new Float64Array(16);
const k = 1 / det;
// строки обратной 3×3 → в столбцовый порядок
const n = [A * k, -(b * i - c * h) * k, (b * f - c * e) * k, B * k, (a * i - c * g) * k, -(a * f - c * d) * k,
C * k, -(a * h - b * g) * k, (a * e - b * d) * k];
r[0] = n[0]; r[4] = n[1]; r[8] = n[2]; r[1] = n[3]; r[5] = n[4]; r[9] = n[5]; r[2] = n[6]; r[6] = n[7]; r[10] = n[8];
r[12] = -(r[0] * m[12] + r[4] * m[13] + r[8] * m[14]);
r[13] = -(r[1] * m[12] + r[5] * m[13] + r[9] * m[14]);
r[14] = -(r[2] * m[12] + r[6] * m[13] + r[10] * m[14]);
r[15] = 1;
return r;
}
/** Перенос и поворот (масштаб отбрасывается — у костей рига его нет). */
function decompose(m: M4): { t: V3; q: Q } {
const sx = Math.hypot(m[0], m[1], m[2]), sy = Math.hypot(m[4], m[5], m[6]), sz = Math.hypot(m[8], m[9], m[10]);
const r = [m[0] / sx, m[1] / sx, m[2] / sx, m[4] / sy, m[5] / sy, m[6] / sy, m[8] / sz, m[9] / sz, m[10] / sz];
const [m00, m10, m20, m01, m11, m21, m02, m12, m22] = r;
const tr = m00 + m11 + m22;
let q: Q;
if (tr > 0) { const s = Math.sqrt(tr + 1) * 2; q = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, 0.25 * s]; }
else if (m00 > m11 && m00 > m22) { const s = Math.sqrt(1 + m00 - m11 - m22) * 2; q = [0.25 * s, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s]; }
else if (m11 > m22) { const s = Math.sqrt(1 + m11 - m00 - m22) * 2; q = [(m01 + m10) / s, 0.25 * s, (m12 + m21) / s, (m02 - m20) / s]; }
else { const s = Math.sqrt(1 + m22 - m00 - m11) * 2; q = [(m02 + m20) / s, (m12 + m21) / s, 0.25 * s, (m10 - m01) / s]; }
const n = Math.hypot(...q);
return { t: [m[12], m[13], m[14]], q: q.map(x => x / n) as Q };
}
function slerp(a: Q, b: Q, t: number): Q {
let d = a[0] * b[0] + a[1] * b[1] + a[2] * b[2] + a[3] * b[3];
const bb = d < 0 ? b.map(x => -x) as Q : b;
d = Math.abs(d);
if (d > 0.9995) {
const r = a.map((x, i) => x + (bb[i] - x) * t) as Q;
const n = Math.hypot(...r);
return r.map(x => x / n) as Q;
}
const th = Math.acos(d), s = Math.sin(th);
const wa = Math.sin((1 - t) * th) / s, wb = Math.sin(t * th) / s;
return a.map((x, i) => x * wa + bb[i] * wb) as Q;
}
/** Данные аксессора как массив чисел (float или нормализованные целые). */
function accessor(json: Json, bin: Buffer, index: number): { data: number[]; size: number; count: number } {
const acc = json.accessors[index];
const view = json.bufferViews[acc.bufferView];
const size = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16 }[acc.type as string] ?? 1;
const comp = acc.componentType as number;
const bytes = { 5126: 4, 5125: 4, 5123: 2, 5122: 2, 5121: 1, 5120: 1 }[comp] ?? 4;
const stride = view.byteStride ?? size * bytes;
const base = (view.byteOffset ?? 0) + (acc.byteOffset ?? 0);
const data: number[] = [];
for (let i = 0; i < acc.count; i++) for (let k = 0; k < size; k++) {
const o = base + i * stride + k * bytes;
let v = comp === 5126 ? bin.readFloatLE(o) : comp === 5125 ? bin.readUInt32LE(o) : comp === 5123 ? bin.readUInt16LE(o)
: comp === 5122 ? bin.readInt16LE(o) : comp === 5121 ? bin.readUInt8(o) : bin.readInt8(o);
if (acc.normalized) v = comp === 5123 ? v / 65535 : comp === 5122 ? Math.max(v / 32767, -1) : comp === 5121 ? v / 255 : Math.max(v / 127, -1);
data.push(v);
}
return { data, size, count: acc.count };
}
interface Track { times: number[]; values: number[]; size: number; interp: string }
function sample(tr: Track, t: number): number[] {
const { times, values, size } = tr;
const cubic = tr.interp === 'CUBICSPLINE';
const at = (i: number) => (cubic ? values.slice((i * 3 + 1) * size, (i * 3 + 2) * size) : values.slice(i * size, (i + 1) * size));
if (t <= times[0]) return at(0);
if (t >= times[times.length - 1]) return at(times.length - 1);
let i = 0;
while (times[i + 1] < t) i++;
const a = at(i), b = at(i + 1);
if (tr.interp === 'STEP') return a;
const u = (t - times[i]) / (times[i + 1] - times[i]);
if (size === 4) return slerp(a as Q, b as Q, u);
return a.map((x, k) => x + (b[k] - x) * u);
}
/**
* Анимации исходного скелета (src) как новые анимации для суставов рига (rig — JSON результата SkinTokens, суставы
* сопоставляются по имени). Возвращает glTF animations + данные аксессоров; addAccessor добавляет их в итоговый GLB.
*/
export function retargetAnimations(
src: { json: Json; bin: Buffer }, rig: Json, rigJoints: number[],
addAccessor: (values: number[], type: 'SCALAR' | 'VEC3' | 'VEC4', minMax?: boolean) => number,
nodeOffset: number,
): Json[] {
const sj = src.json;
const sNodes: Json[] = sj.nodes;
const parentOf = new Map<number, number>();
sNodes.forEach((n, i) => (n.children ?? []).forEach((c: number) => parentOf.set(c, i)));
const byName = new Map<string, number>();
sNodes.forEach((n, i) => { if (n.name) byName.set(n.name, i); });
const pairs = rigJoints.map(j => [j, byName.get(rig.nodes[j].name)] as const).filter((p): p is readonly [number, number] => p[1] !== undefined);
if (pairs.length < 2) return [];
const restLocal = (i: number): M4 => {
const n = sNodes[i];
if (n.matrix) return Float64Array.from(n.matrix);
return compose(n.translation ?? [0, 0, 0], n.rotation ?? [0, 0, 0, 1], n.scale ?? [1, 1, 1]);
};
const world = (local: (i: number) => M4) => {
const cache = new Map<number, M4>();
const w = (i: number): M4 => {
let m = cache.get(i);
if (!m) {
const p = parentOf.get(i);
m = p === undefined ? local(i) : mul(w(p), local(i));
cache.set(i, m);
}
return m;
};
return w;
};
const sRest = world(restLocal);
// поза покоя рига: суставы без промежуточных узлов (skin-tokens.cpp), мировые матрицы по иерархии суставов
const rParent = new Map<number, number>();
rigJoints.forEach(j => (rig.nodes[j].children ?? []).forEach((c: number) => rParent.set(c, j)));
const rLocal = (j: number) => compose(rig.nodes[j].translation ?? [0, 0, 0], rig.nodes[j].rotation ?? [0, 0, 0, 1], rig.nodes[j].scale ?? [1, 1, 1]);
const rRestCache = new Map<number, M4>();
const rRest = (j: number): M4 => {
let m = rRestCache.get(j);
if (!m) { const p = rParent.get(j); m = p === undefined ? rLocal(j) : mul(rRest(p), rLocal(j)); rRestCache.set(j, m); }
return m;
};
// исходник → риг: равномерный масштаб и сдвиг по положениям суставов (МНК)
const ps = pairs.map(([, s]) => [sRest(s)[12], sRest(s)[13], sRest(s)[14]]);
const pr = pairs.map(([r]) => [rRest(r)[12], rRest(r)[13], rRest(r)[14]]);
const mean = (a: number[][]) => [0, 1, 2].map(k => a.reduce((s, v) => s + v[k], 0) / a.length);
const ms = mean(ps), mr = mean(pr);
let num = 0, den = 0;
ps.forEach((p, i) => { for (let k = 0; k < 3; k++) { num += (p[k] - ms[k]) * (pr[i][k] - mr[k]); den += (p[k] - ms[k]) ** 2; } });
const sc = den > 0 ? num / den : 1;
const F = compose([mr[0] - sc * ms[0], mr[1] - sc * ms[1], mr[2] - sc * ms[2]], [0, 0, 0, 1], [sc, sc, sc]);
const Finv = inv(F);
const out: Json[] = [];
for (const anim of (sj.animations ?? []) as Json[]) {
const tracks = new Map<string, Track>(); // `${узел}/${path}`
const times = new Set<number>();
for (const ch of anim.channels as Json[]) {
if (!['translation', 'rotation', 'scale'].includes(ch.target?.path) || ch.target.node === undefined) continue;
const smp = anim.samplers[ch.sampler];
const input = accessor(sj, src.bin, smp.input).data;
const output = accessor(sj, src.bin, smp.output);
input.forEach(t => times.add(t));
tracks.set(`${ch.target.node}/${ch.target.path}`, { times: input, values: output.data, size: output.size, interp: smp.interpolation ?? 'LINEAR' });
}
const T = [...times].sort((a, b) => a - b);
if (!T.length) continue;
const trans = new Map<number, number[]>(), rots = new Map<number, number[]>();
for (const [r] of pairs) { trans.set(r, []); rots.set(r, []); }
for (const t of T) {
const frameLocal = (i: number): M4 => {
const n = sNodes[i];
const tr = tracks.get(`${i}/translation`), ro = tracks.get(`${i}/rotation`), s = tracks.get(`${i}/scale`);
if (!tr && !ro && !s) return restLocal(i);
return compose((tr ? sample(tr, t) : n.translation ?? [0, 0, 0]) as V3, (ro ? sample(ro, t) : n.rotation ?? [0, 0, 0, 1]) as Q,
(s ? sample(s, t) : n.scale ?? [1, 1, 1]) as V3);
};
const sFrame = world(frameLocal);
const rFrame = new Map<number, M4>();
for (const [r, s] of pairs) {
const D = mul(mul(F, mul(sFrame(s), inv(sRest(s)))), Finv); // мировое движение сустава, в координатах рига
rFrame.set(r, mul(D, rRest(r)));
}
for (const [r] of pairs) {
const p = rParent.get(r);
const parentW = p === undefined ? ident() : rFrame.get(p) ?? rRest(p);
const { t: tt, q } = decompose(mul(inv(parentW), rFrame.get(r)!));
trans.get(r)!.push(...tt);
rots.get(r)!.push(...q);
}
}
const input = addAccessor(T, 'SCALAR', true);
const samplers: Json[] = [], channels: Json[] = [];
for (const [r] of pairs) {
samplers.push({ input, output: addAccessor(trans.get(r)!, 'VEC3'), interpolation: 'LINEAR' });
channels.push({ sampler: samplers.length - 1, target: { node: r + nodeOffset, path: 'translation' } });
samplers.push({ input, output: addAccessor(rots.get(r)!, 'VEC4'), interpolation: 'LINEAR' });
channels.push({ sampler: samplers.length - 1, target: { node: r + nodeOffset, path: 'rotation' } });
}
out.push({ ...(anim.name ? { name: anim.name } : {}), samplers, channels });
}
return out;
}
+101 -20
View File
@@ -4,7 +4,7 @@ import path from 'node:path';
import { DatabaseSync } from 'node:sqlite'; import { DatabaseSync } from 'node:sqlite';
import type { import type {
BatchImage, BatchState, FileRef, ImageBatch, Job, JobState, ModelArtifacts, Project, SettingsPage, Text2ImgInput, BatchImage, BatchState, FileRef, ImageBatch, Job, JobState, ModelArtifacts, Project, SettingsPage, Text2ImgInput,
TextureArtifacts, AnimArtifacts, RigArtifacts, TextureArtifacts,
} from '../shared/api.ts'; } from '../shared/api.ts';
import { DB_PATH } from './config.ts'; import { DB_PATH } from './config.ts';
@@ -79,12 +79,13 @@ db.exec(`
); );
CREATE INDEX IF NOT EXISTS Images_batchId ON Images (batchId); CREATE INDEX IF NOT EXISTS Images_batchId ON Images (batchId);
-- проект: работа над одной картинкой из батча (из одной картинки — только один проект) -- проект: работа над одной картинкой из батча (из одной картинки — сколько угодно проектов)
CREATE TABLE IF NOT EXISTS Projects ( CREATE TABLE IF NOT EXISTS Projects (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
created INTEGER NOT NULL, -- мс created INTEGER NOT NULL, -- мс
imageId INTEGER NOT NULL UNIQUE REFERENCES Images(id) ON DELETE CASCADE imageId INTEGER NOT NULL REFERENCES Images(id) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS Projects_imageId ON Projects (imageId);
CREATE TABLE IF NOT EXISTS meta ( CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -125,7 +126,35 @@ addColumns('Projects', {
texture: 'TEXT', // JSON TextureArtifacts: текстурированная модель, текстуры, развёртка, виды texture: 'TEXT', // JSON TextureArtifacts: текстурированная модель, текстуры, развёртка, виды
textureJobId: 'TEXT', // prompt_id идущего текстурирования textureJobId: 'TEXT', // prompt_id идущего текстурирования
textureError: 'TEXT', // ошибка последнего текстурирования textureError: 'TEXT', // ошибка последнего текстурирования
rig: 'TEXT', // JSON RigArtifacts: риг SkinTokens
rigJobId: 'TEXT', // 'skintokens:<мс>' — идёт риггинг (процесс сервера)
rigError: 'TEXT', // ошибка последнего риггинга
anim: 'TEXT', // JSON AnimArtifacts[] (раньше — одна): движения на сгенерированном скелете рига
animError: 'TEXT', // ошибка последнего ретаргета
}); });
// риггинг — процесс самого сервера: после перезапуска незавершённый риггинг потерян
db.exec(`UPDATE Projects SET rigError = 'прервано: сервер перезапускался', rigJobId = NULL WHERE rigJobId IS NOT NULL`);
// раньше из картинки можно было сделать только один проект (imageId UNIQUE) — таблица пересобирается без ограничения
if ((db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'Projects'").get() as { sql: string }).sql
.match(/imageId\s+INTEGER\s+NOT\s+NULL\s+UNIQUE/i)) {
const cols = (db.prepare('PRAGMA table_info(Projects)').all() as { name: string; type: string }[]);
const extra = cols.filter(c => !['id', 'created', 'imageId'].includes(c.name));
const names = cols.map(c => c.name).join(', ');
db.exec('PRAGMA foreign_keys = OFF');
db.exec('BEGIN');
db.exec(`CREATE TABLE Projects_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created INTEGER NOT NULL,
imageId INTEGER NOT NULL REFERENCES Images(id) ON DELETE CASCADE
${extra.map(c => `, ${c.name} ${c.type}`).join('')}
)`);
db.exec(`INSERT INTO Projects_new (${names}) SELECT ${names} FROM Projects`);
db.exec('DROP TABLE Projects');
db.exec('ALTER TABLE Projects_new RENAME TO Projects');
db.exec('CREATE INDEX IF NOT EXISTS Projects_imageId ON Projects (imageId)');
db.exec('COMMIT');
db.exec('PRAGMA foreign_keys = ON');
}
// истории очисток, 3D-моделей и текстур не хранятся (результат — в проекте) — завершённые задачи удаляются // истории очисток, 3D-моделей и текстур не хранятся (результат — в проекте) — завершённые задачи удаляются
db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model', 'texture') AND state <> 'pending'`); db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model', 'texture') AND state <> 'pending'`);
@@ -215,12 +244,12 @@ interface BatchRow {
files: string | null; files: string | null;
} }
interface ImageRow { id: number; batchId: number; filename: string; subfolder: string; type: string; projectId: number | null } interface ImageRow { id: number; batchId: number; filename: string; subfolder: string; type: string; projectIds: string | null }
/** Картинки с id и проектом (если из картинки создан проект). */ /** Картинки с id и проектами, созданными из них (через запятую). */
const SELECT_IMAGES = ` const SELECT_IMAGES = `
SELECT i.id, i.batchId, i.filename, i.subfolder, i.type, p.id AS projectId SELECT i.id, i.batchId, i.filename, i.subfolder, i.type, (SELECT GROUP_CONCAT(id) FROM Projects WHERE imageId = i.id) AS projectIds
FROM Images i LEFT JOIN Projects p ON p.imageId = i.id`; FROM Images i`;
const SELECT_BATCH = ` const SELECT_BATCH = `
SELECT b.*, t.shape, t.colors, t.template, t.negative, t.batchSize, t.seed, u.files SELECT b.*, t.shape, t.colors, t.template, t.negative, t.batchSize, t.seed, u.files
@@ -253,8 +282,10 @@ const bq = {
imageBatch: db.prepare('SELECT batchId FROM Images WHERE type = ? AND subfolder = ? AND filename = ?'), imageBatch: db.prepare('SELECT batchId FROM Images WHERE type = ? AND subfolder = ? AND filename = ?'),
}; };
const ids = (s: string | null) => (s ? s.split(',').map(Number).sort((a, b) => a - b) : []);
const ref = (i: ImageRow): BatchImage => ({ const ref = (i: ImageRow): BatchImage => ({
id: i.id, filename: i.filename, subfolder: i.subfolder, type: i.type, projectId: i.projectId, id: i.id, filename: i.filename, subfolder: i.subfolder, type: i.type, projectIds: ids(i.projectIds),
}); });
function toBatch(row: BatchRow, images: BatchImage[]): ImageBatch { function toBatch(row: BatchRow, images: BatchImage[]): ImageBatch {
@@ -353,13 +384,13 @@ export function deleteBatch(b: ImageBatch) {
const SELECT_PROJECT = ` const SELECT_PROJECT = `
SELECT p.id, p.created, p.clean, p.cleanUpdated, p.cleanJobId, p.cleanError, SELECT p.id, p.created, p.clean, p.cleanUpdated, p.cleanJobId, p.cleanError,
p.model, p.modelJobId, p.modelError, p.texture, p.textureJobId, p.textureError, p.model, p.modelJobId, p.modelError, p.texture, p.textureJobId, p.textureError, p.rig, p.rigJobId, p.rigError, p.anim, p.animError,
i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type, b.sourceType i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type, b.sourceType,
(SELECT GROUP_CONCAT(id) FROM Projects WHERE imageId = i.id) AS projectIds
FROM Projects p JOIN Images i ON i.id = p.imageId JOIN ImageBatches b ON b.id = i.batchId`; FROM Projects p JOIN Images i ON i.id = p.imageId JOIN ImageBatches b ON b.id = i.batchId`;
const pq = { const pq = {
insert: db.prepare('INSERT OR IGNORE INTO Projects (created, imageId) VALUES (?, ?)'), insert: db.prepare('INSERT INTO Projects (created, imageId) VALUES (?, ?)'),
byImage: db.prepare('SELECT id FROM Projects WHERE imageId = ?'),
get: db.prepare(SELECT_PROJECT + ' WHERE p.id = ?'), get: db.prepare(SELECT_PROJECT + ' WHERE p.id = ?'),
list: db.prepare(SELECT_PROJECT + ' ORDER BY p.created DESC, p.id DESC LIMIT ?'), list: db.prepare(SELECT_PROJECT + ' ORDER BY p.created DESC, p.id DESC LIMIT ?'),
byCleanJob: db.prepare('SELECT id FROM Projects WHERE cleanJobId = ?'), byCleanJob: db.prepare('SELECT id FROM Projects WHERE cleanJobId = ?'),
@@ -371,12 +402,25 @@ const pq = {
// новая модель заменяет прежнюю вместе с текстурами (они от прежней модели); при ошибке всё остаётся // новая модель заменяет прежнюю вместе с текстурами (они от прежней модели); при ошибке всё остаётся
finishModel: db.prepare(`UPDATE Projects SET model = COALESCE(?1, model), finishModel: db.prepare(`UPDATE Projects SET model = COALESCE(?1, model),
texture = CASE WHEN ?1 IS NULL THEN texture ELSE ?2 END, texture = CASE WHEN ?1 IS NULL THEN texture ELSE ?2 END,
rig = CASE WHEN ?1 IS NULL THEN rig ELSE NULL END,
anim = CASE WHEN ?1 IS NULL THEN anim ELSE NULL END,
modelError = ?3, modelJobId = NULL WHERE id = ?4`), modelError = ?3, modelJobId = NULL WHERE id = ?4`),
byTextureJob: db.prepare('SELECT id FROM Projects WHERE textureJobId = ?'), byTextureJob: db.prepare('SELECT id FROM Projects WHERE textureJobId = ?'),
startTexture: db.prepare('UPDATE Projects SET textureJobId = ?, textureError = NULL WHERE id = ?'), startTexture: db.prepare('UPDATE Projects SET textureJobId = ?, textureError = NULL WHERE id = ?'),
finishTexture: db.prepare('UPDATE Projects SET texture = COALESCE(?, texture), textureError = ?, textureJobId = NULL WHERE id = ?'), finishTexture: db.prepare(`UPDATE Projects SET texture = COALESCE(?1, texture), rig = CASE WHEN ?1 IS NULL THEN rig ELSE NULL END,
clearModel: db.prepare('UPDATE Projects SET model = NULL, modelError = NULL, texture = NULL, textureError = NULL WHERE id = ?'), anim = CASE WHEN ?1 IS NULL THEN anim ELSE NULL END,
clearTexture: db.prepare('UPDATE Projects SET texture = NULL, textureError = NULL WHERE id = ?'), textureError = ?2, textureJobId = NULL WHERE id = ?3`),
clearModel: db.prepare(`UPDATE Projects SET model = NULL, modelError = NULL, texture = NULL, textureError = NULL, rig = NULL,
rigError = NULL, anim = NULL, animError = NULL WHERE id = ?`),
clearTexture: db.prepare(`UPDATE Projects SET texture = NULL, textureError = NULL, rig = NULL, rigError = NULL, anim = NULL,
animError = NULL WHERE id = ?`),
startRig: db.prepare('UPDATE Projects SET rigJobId = ?, rigError = NULL WHERE id = ?'),
finishRig: db.prepare(`UPDATE Projects SET rig = COALESCE(?1, rig), anim = CASE WHEN ?1 IS NULL THEN anim ELSE NULL END,
rigError = ?2, rigJobId = NULL WHERE id = ?3`),
clearRig: db.prepare('UPDATE Projects SET rig = NULL, rigError = NULL, anim = NULL, animError = NULL WHERE id = ?'),
getAnims: db.prepare('SELECT anim FROM Projects WHERE id = ?'),
setAnims: db.prepare('UPDATE Projects SET anim = ?, animError = ? WHERE id = ?'),
setAnimError: db.prepare('UPDATE Projects SET animError = ? WHERE id = ?'),
}; };
/** Генерация 3D-модели проекта поставлена в очередь. */ /** Генерация 3D-модели проекта поставлена в очередь. */
@@ -420,6 +464,40 @@ export function clearProjectTexture(id: number) {
pq.clearTexture.run(id); pq.clearTexture.run(id);
} }
/** Риггинг проекта начат (процесс сервера). */
export function startProjectRig(id: number, jobId: string) {
pq.startRig.run(jobId, id);
}
/** Риггинг завершён: новый риг заменяет прежний (при ошибке прежний остаётся). */
export function finishProjectRig(id: number, rig: RigArtifacts | null, error?: string) {
pq.finishRig.run(rig ? JSON.stringify(rig) : null, error ?? null, id);
}
/** Список анимаций из столбца anim (раньше там хранилась одна — объект). */
function animsOf(value: string | null): AnimArtifacts[] {
const v = value ? JSON.parse(value) : null;
return Array.isArray(v) ? v : v ? [v] : [];
}
/** Ретаргет завершён: новая анимация добавляется к прежним; при ошибке — только текст ошибки. */
export function addProjectAnim(id: number, anim: AnimArtifacts | null, error?: string) {
if (!anim) { pq.setAnimError.run(error ?? null, id); return; }
const list = animsOf((pq.getAnims.get(id) as { anim: string | null }).anim);
pq.setAnims.run(JSON.stringify([...list, anim]), null, id);
}
/** Убрать одну анимацию (по имени файла) из проекта; файл в ComfyUI/output остаётся. */
export function removeProjectAnim(id: number, filename: string) {
const list = animsOf((pq.getAnims.get(id) as { anim: string | null }).anim).filter(a => a.glb.filename !== filename);
pq.setAnims.run(list.length ? JSON.stringify(list) : null, null, id);
}
/** Убрать риг из проекта вместе с анимацией на нём (файлы в ComfyUI/output остаются). */
export function clearProjectRig(id: number) {
pq.clearRig.run(id);
}
/** Убрать очищенную картинку из проекта (файл в ComfyUI/output остаётся). */ /** Убрать очищенную картинку из проекта (файл в ComfyUI/output остаётся). */
export function clearProjectClean(id: number) { export function clearProjectClean(id: number) {
pq.clearClean.run(id); pq.clearClean.run(id);
@@ -429,7 +507,9 @@ interface ProjectRow {
id: number; created: number; clean: string | null; cleanUpdated: number | null; cleanJobId: string | null; cleanError: string | null; id: number; created: number; clean: string | null; cleanUpdated: number | null; cleanJobId: string | null; cleanError: string | null;
model: string | null; modelJobId: string | null; modelError: string | null; model: string | null; modelJobId: string | null; modelError: string | null;
texture: string | null; textureJobId: string | null; textureError: string | null; texture: string | null; textureJobId: string | null; textureError: string | null;
rig: string | null; rigJobId: string | null; rigError: string | null; anim: string | null; animError: string | null;
imageId: number; batchId: number; filename: string; subfolder: string; type: string; sourceType: string; imageId: number; batchId: number; filename: string; subfolder: string; type: string; sourceType: string;
projectIds: string | null;
} }
const json = (s: string | null) => (s ? JSON.parse(s) : null); const json = (s: string | null) => (s ? JSON.parse(s) : null);
@@ -437,11 +517,13 @@ const json = (s: string | null) => (s ? JSON.parse(s) : null);
function toProject(r: ProjectRow): Project { function toProject(r: ProjectRow): Project {
return { return {
id: r.id, created: r.created, batchId: r.batchId, imageSource: r.sourceType === 'upload' ? 'upload' : 'text', id: r.id, created: r.created, batchId: r.batchId, imageSource: r.sourceType === 'upload' ? 'upload' : 'text',
image: { id: r.imageId, filename: r.filename, subfolder: r.subfolder, type: r.type, projectId: r.id }, image: { id: r.imageId, filename: r.filename, subfolder: r.subfolder, type: r.type, projectIds: ids(r.projectIds) },
clean: json(r.clean), cleanUpdated: r.cleanUpdated, clean: json(r.clean), cleanUpdated: r.cleanUpdated,
cleanJobId: r.cleanJobId, ...(r.cleanError ? { cleanError: r.cleanError } : {}), cleanJobId: r.cleanJobId, ...(r.cleanError ? { cleanError: r.cleanError } : {}),
model: json(r.model), modelJobId: r.modelJobId, ...(r.modelError ? { modelError: r.modelError } : {}), model: json(r.model), modelJobId: r.modelJobId, ...(r.modelError ? { modelError: r.modelError } : {}),
texture: json(r.texture), textureJobId: r.textureJobId, ...(r.textureError ? { textureError: r.textureError } : {}), texture: json(r.texture), textureJobId: r.textureJobId, ...(r.textureError ? { textureError: r.textureError } : {}),
rig: json(r.rig), rigJobId: r.rigJobId, ...(r.rigError ? { rigError: r.rigError } : {}),
anims: animsOf(r.anim), ...(r.animError ? { animError: r.animError } : {}),
}; };
} }
@@ -468,10 +550,9 @@ export function getImage(id: number): (BatchImage & { batchId: number }) | null
return row ? { ...ref(row), batchId: row.batchId } : null; return row ? { ...ref(row), batchId: row.batchId } : null;
} }
/** Проект из картинки: из одной картинки — только один проект; если уже есть, возвращается он. */ /** Новый проект из картинки (из одной картинки — сколько угодно проектов). */
export function projectForImage(imageId: number): number { export function createProjectFor(imageId: number): number {
pq.insert.run(Date.now(), imageId); return Number(pq.insert.run(Date.now(), imageId).lastInsertRowid);
return (pq.byImage.get(imageId) as { id: number }).id;
} }
export function getProject(id: number): Project | null { export function getProject(id: number): Project | null {
+263
View File
@@ -2,6 +2,7 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import type { FileRef } from '../shared/api.ts'; import type { FileRef } from '../shared/api.ts';
import { retargetAnimations } from './anim.ts';
import { COMFY_OUTPUT } from './config.ts'; import { COMFY_OUTPUT } from './config.ts';
interface Gltf { interface Gltf {
@@ -67,3 +68,265 @@ export function glbTriangles(ref: FileRef): number | null {
if (fd !== undefined) fs.closeSync(fd); 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 };
}
+173 -14
View File
@@ -10,19 +10,21 @@ import type {
CleanupJob, CleanupRequest, FileRef, ImageBatch, ImportRequest, ModelJob, Project, ProjectModelRequest, ProjectTextureRequest, CleanupJob, CleanupRequest, FileRef, ImageBatch, ImportRequest, ModelJob, Project, ProjectModelRequest, ProjectTextureRequest,
QueueInfo, SettingsPage, Text2ImgInput, Text2ImgJob, TextureJob, QueueInfo, SettingsPage, Text2ImgInput, Text2ImgJob, TextureJob,
} from '../shared/api.ts'; } from '../shared/api.ts';
import { DEFAULT_TEMPLATE, isLocked } from '../shared/api.ts'; import { DEFAULT_TEMPLATE, isLocked, isUntextured } from '../shared/api.ts';
import { COMFY, DB_PATH, DIST, HOST, PORT } from './config.ts'; import { COMFY, COMFY_OUTPUT, DB_PATH, DIST, HOST, PORT } from './config.ts';
import { ComfyError, comfyJson, queuePrompt, uploadImage, view } from './comfy.ts'; import { ComfyError, comfyJson, queuePrompt, uploadImage, view } from './comfy.ts';
import { import {
clearProjectClean, clearProjectModel, clearProjectTexture, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage, addProjectAnim, clearProjectClean, clearProjectModel, clearProjectRig, clearProjectTexture, finishProjectRig, removeProjectAnim,
getProject, getSettings, insertJob, listBatches, listProjects, projectForImage, putSettings, startBatch, startProjectClean, startProjectRig, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage,
getProject, getSettings, insertJob, listBatches, listProjects, createProjectFor, putSettings, startBatch, startProjectClean,
startProjectModel, startProjectTexture, transaction, updateTextInput, startProjectModel, startProjectTexture, transaction, updateTextInput,
} from './db.ts'; } from './db.ts';
import { glbWithoutNormals, outputPath } from './glb.ts'; import { glbTriangles, glbWithoutNormals, graftSkin, outputPath, normalizeHumanoid } from './glb.ts';
import { rigBusy, runRetarget, runSkinTokens, type Fit } from './rig.ts';
import { migrateOldRuns } from './migrate.ts'; import { migrateOldRuns } from './migrate.ts';
import { liveStatus, startTracker } from './tracker.ts'; import { liveStatus, startTracker } from './tracker.ts';
import { import {
cleanupGraph, escapeTemplate, kindOf, modelGraph, paintGraph, renderTemplate, text2imgGraph, trellisGraph, cleanupGraph, escapeTemplate, hy3dWrapperGraph, kindOf, modelGraph, paintGraph, renderTemplate, text2imgGraph, trellisGraph,
} from './workflows.ts'; } from './workflows.ts';
class BadRequest extends Error {} class BadRequest extends Error {}
@@ -35,7 +37,7 @@ const UPLOAD_DIR = 'img2glb_upload';
type Req = http.IncomingMessage; type Req = http.IncomingMessage;
type Res = http.ServerResponse; type Res = http.ServerResponse;
const SETTINGS_PAGES: SettingsPage[] = ['text2img', 'cleanup', 'model', 'texture']; const SETTINGS_PAGES: SettingsPage[] = ['text2img', 'cleanup', 'model', 'texture', 'rig', 'retarget'];
function send(res: Res, code: number, obj: unknown) { function send(res: Res, code: number, obj: unknown) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
@@ -198,7 +200,7 @@ async function uploadBatch(req: Req, res: Res) {
async function createProject(req: Req, res: Res) { async function createProject(req: Req, res: Res) {
const imageId = Number((await readJson<{ imageId: number }>(req)).imageId); const imageId = Number((await readJson<{ imageId: number }>(req)).imageId);
if (!Number.isInteger(imageId) || !getImage(imageId)) throw new NotFound('картинка не найдена'); if (!Number.isInteger(imageId) || !getImage(imageId)) throw new NotFound('картинка не найдена');
send(res, 200, withLiveProject(projectOrThrow(projectForImage(imageId)))); send(res, 200, withLiveProject(projectOrThrow(createProjectFor(imageId))));
} }
function projectOrThrow(id: number): Project { function projectOrThrow(id: number): Project {
@@ -226,9 +228,10 @@ async function projectModel(req: Req, res: Res, projectId: number) {
if (!p.clean) throw new Conflict('сначала почистите исходник'); if (!p.clean) throw new Conflict('сначала почистите исходник');
if (p.modelJobId) throw new Conflict('модель уже генерируется'); if (p.modelJobId) throw new Conflict('модель уже генерируется');
if (p.textureJobId) throw new Conflict('идёт текстурирование'); if (p.textureJobId) throw new Conflict('идёт текстурирование');
if (p.rigJobId) throw new Conflict('идёт риггинг');
const b = await readJson<ProjectModelRequest>(req); const b = await readJson<ProjectModelRequest>(req);
const src = sourceImage(p.clean); const src = sourceImage(p.clean);
const generator = b.generator === 'trellis' ? 'trellis' : 'hunyuan'; const generator = b.generator === 'trellis' || b.generator === 'hy3dwrapper' ? b.generator : 'hunyuan';
let id: string; let id: string;
let params: ModelJob['params']; let params: ModelJob['params'];
if (generator === 'trellis') { if (generator === 'trellis') {
@@ -238,6 +241,16 @@ async function projectModel(req: Req, res: Res, projectId: number) {
const pbr = b.pbr === true; const pbr = b.pbr === true;
id = await queuePrompt(trellisGraph({ image: src.image, prefix: 'img2glb_trellis/' + src.stem, seed, faces, textureSize, pbr })); id = await queuePrompt(trellisGraph({ image: src.image, prefix: 'img2glb_trellis/' + src.stem, seed, faces, textureSize, pbr }));
params = { projectId, generator, seed, faces, textureSize, pbr }; params = { projectId, generator, seed, faces, textureSize, pbr };
} else if (generator === 'hy3dwrapper') {
const seed = parseSeed(b.seed);
const steps = Math.round(clamp(b.steps, 1, 100, 30));
const octree = Math.round(clamp(b.octree, 64, 512, 380) / 8) * 8; // Hy3DVAEDecode: шаг 8
const faces = Math.round(clamp(b.faces, 100, 2_000_000, 5000));
const remeshVertices = b.remesh ? Math.round(clamp(b.remeshVertices, 100, 2_000_000, 5000)) : 0;
id = await queuePrompt(hy3dWrapperGraph({
image: src.image, prefix: 'img2glb_hy3d/' + src.stem, seed, steps, octree, faces, remeshVertices,
}));
params = { projectId, generator, seed, steps, octree, faces, remeshVertices };
} else { } else {
const seed = parseSeed(b.seed); const seed = parseSeed(b.seed);
const steps = Math.round(clamp(b.steps, 1, 100, 30)); const steps = Math.round(clamp(b.steps, 1, 100, 30));
@@ -259,7 +272,7 @@ async function projectModel(req: Req, res: Res, projectId: number) {
/** Убрать модель из проекта вместе со всеми текстурами — после этого можно сгенерировать заново. */ /** Убрать модель из проекта вместе со всеми текстурами — после этого можно сгенерировать заново. */
function removeProjectModel(res: Res, id: number) { function removeProjectModel(res: Res, id: number) {
const p = projectOrThrow(id); const p = projectOrThrow(id);
if (p.modelJobId || p.textureJobId) throw new Conflict('идёт генерация'); if (p.modelJobId || p.textureJobId || p.rigJobId) throw new Conflict('идёт генерация');
clearProjectModel(id); clearProjectModel(id);
send(res, 200, withLiveProject(projectOrThrow(id))); send(res, 200, withLiveProject(projectOrThrow(id)));
} }
@@ -272,8 +285,8 @@ async function projectTexture(req: Req, res: Res, projectId: number) {
const p = projectOrThrow(projectId); const p = projectOrThrow(projectId);
if (!p.clean) throw new Conflict('сначала почистите исходник'); if (!p.clean) throw new Conflict('сначала почистите исходник');
if (!p.model) throw new Conflict('сначала сделайте 3D-модель'); if (!p.model) throw new Conflict('сначала сделайте 3D-модель');
if (p.model.generator !== 'hunyuan') throw new Conflict('модель уже с текстурами'); if (!isUntextured(p.model.generator)) throw new Conflict('модель уже с текстурами');
if (p.modelJobId || p.textureJobId) throw new Conflict('идёт генерация'); if (p.modelJobId || p.textureJobId || p.rigJobId) throw new Conflict('идёт генерация');
const b = await readJson<ProjectTextureRequest>(req); const b = await readJson<ProjectTextureRequest>(req);
const src = sourceImage(p.clean); const src = sourceImage(p.clean);
const seed = parseSeed(b.seed) % 2 ** 32; const seed = parseSeed(b.seed) % 2 ** 32;
@@ -299,12 +312,153 @@ async function projectTexture(req: Req, res: Res, projectId: number) {
*/ */
function removeProjectTexture(res: Res, id: number) { function removeProjectTexture(res: Res, id: number) {
const p = projectOrThrow(id); const p = projectOrThrow(id);
if (p.modelJobId || p.textureJobId) throw new Conflict('идёт генерация'); if (p.modelJobId || p.textureJobId || p.rigJobId) throw new Conflict('идёт генерация');
if (p.texture?.source === 'trellis') clearProjectModel(id); if (p.texture?.source === 'trellis') clearProjectModel(id);
else clearProjectTexture(id); else clearProjectTexture(id);
send(res, 200, withLiveProject(projectOrThrow(id))); send(res, 200, withLiveProject(projectOrThrow(id)));
} }
const RIG_DIR = 'img2glb_rig';
/** Готовые скелеты (GLB, можно с анимацией) для рига — выбираются в форме по относительному пути. */
const MOTIONS_DIR = path.join(import.meta.dirname, '..', 'data', 'motions');
/** GLB в app/data/motions и подпапках: относительные пути через «/», по алфавиту. */
function listMotions(): string[] {
if (!fs.existsSync(MOTIONS_DIR)) return [];
return (fs.readdirSync(MOTIONS_DIR, { recursive: true }) as string[])
.filter(f => f.toLowerCase().endsWith('.glb'))
.map(f => f.replace(/\\/g, '/'))
.sort((a, b) => a.localeCompare(b));
}
/**
* Риг SkinTokens (skin-tokens.cpp на CPU в WSL) для модели проекта — текстурированной, если есть.
* multipart: skeleton — необязательный GLB со скелетом или motion — путь GLB в app/data/motions (listMotions);
* тогда skin: только веса под него, с совмещением fit;
* без него — rig: скелет генерируется. skin-tokens.cpp теряет UV и материалы, поэтому скелет и веса переносятся
* на исходный GLB (graftSkin). Работает минуты — ответ сразу, результат пишется в проект по завершении.
*/
async function projectRig(req: Req, res: Res, projectId: number) {
const p = projectOrThrow(projectId);
if (!p.model) throw new Conflict('сначала сделайте 3D-модель');
if (p.modelJobId || p.textureJobId || p.rigJobId) throw new Conflict('идёт генерация');
if (rigBusy()) throw new Conflict('идёт риггинг другого проекта — SkinTokens занимает весь процессор');
let form: FormData;
try {
form = await new Request('http://local/', {
method: 'POST', headers: { 'content-type': String(req.headers['content-type'] ?? '') },
body: Readable.toWeb(req) as ReadableStream, duplex: 'half',
} as RequestInit).formData();
} catch {
throw new BadRequest('ожидается multipart/form-data');
}
const fitValue = String(form.get('fit') ?? 'global');
const fit: Fit = fitValue === 'none' || fitValue === 'articulated' ? fitValue : 'global';
const postprocess = form.get('postprocess') === 'true';
const basedOn = p.texture?.glb ? 'textured' : 'model';
const base = basedOn === 'textured' ? p.texture!.glb! : p.model.glb;
const basePath = outputPath(base);
if (!basePath || !fs.existsSync(basePath)) throw new NotFound('нет файла модели');
const stem = safeName(path.basename(base.filename, '.glb'));
const dir = path.join(COMFY_OUTPUT, RIG_DIR);
fs.mkdirSync(dir, { recursive: true });
const stamp = Date.now();
let skeleton: FileRef | null = null;
const file = form.get('skeleton');
if (file && typeof file !== 'string' && file.size > 0) {
const buf = Buffer.from(await file.arrayBuffer());
if (buf.toString('latin1', 0, 4) !== 'glTF') throw new BadRequest('скелет должен быть GLB');
skeleton = { filename: `${stem}_skeleton_${stamp}.glb`, subfolder: RIG_DIR, type: 'output' };
fs.writeFileSync(path.join(dir, skeleton.filename), buf);
}
// встроенный анимированный скелет (ходьба): веса под него, его анимация переносится в результат
const motion = !skeleton ? String(form.get('motion') ?? '') : '';
if (motion && !listMotions().includes(motion)) throw new BadRequest('нет такого скелета в motions: ' + motion);
const skeletonPath = skeleton ? path.join(dir, skeleton.filename) : motion ? path.join(MOTIONS_DIR, motion) : null;
const raw = path.join(dir, `${stem}_skintokens_${stamp}.glb`);
const out: FileRef = { filename: `${stem}_rig_${stamp}.glb`, subfolder: RIG_DIR, type: 'output' };
const jobId = 'skintokens:' + stamp;
startProjectRig(projectId, jobId);
void runSkinTokens({ mesh: basePath, skeleton: skeletonPath, fit, postprocess, output: raw })
.then(() => {
const glb = graftSkin(fs.readFileSync(basePath), fs.readFileSync(raw), skeletonPath ? fs.readFileSync(skeletonPath) : undefined);
fs.writeFileSync(path.join(dir, out.filename), glb);
fs.rmSync(raw, { force: true });
const joints = (JSON.parse(glb.toString('utf8', 20, 20 + glb.readUInt32LE(12))) as { skins: { joints: unknown[] }[] }).skins[0].joints.length;
finishProjectRig(projectId, {
mode: skeletonPath ? 'skin' : 'rig', glb: out, skeleton, ...(motion ? { motion } : {}), basedOn, faces: glbTriangles(out), joints,
seconds: Math.round((Date.now() - stamp) / 1000), updated: Date.now(),
});
})
.catch((e: Error) => finishProjectRig(projectId, null, e.message));
send(res, 200, withLiveProject(projectOrThrow(projectId)));
}
/**
* Новая анимация из app/data/motions (добавляется к прежним) на сгенерированный SkinTokens скелет рига — отдельным
* шагом, без риггинга (секунды):
* движение Mixamo → SOMA30 под этот скелет → retarget-generated по файлу рига → на исходную модель (graftSkin).
*/
async function projectRetarget(req: Req, res: Res, projectId: number) {
const p = projectOrThrow(projectId);
if (!p.rig || p.rig.mode !== 'rig') throw new Conflict('нужен риг со скелетом, сгенерированным SkinTokens');
if (p.rigJobId) throw new Conflict('идёт риггинг');
const body = await readJson<{ motions?: string[]; motion?: string }>(req);
const motions = [...new Set((body.motions ?? (body.motion ? [body.motion] : [])).map(String))];
if (!motions.length) throw new BadRequest('не выбрано ни одного движения');
const known = listMotions();
const unknown = motions.find(m => !known.includes(m));
if (unknown) throw new BadRequest('нет такого файла в motions: ' + unknown);
const rigPath = outputPath(p.rig.glb);
const base = p.rig.basedOn === 'textured' ? p.texture?.glb : p.model?.glb;
const basePath = base && outputPath(base);
if (!rigPath || !fs.existsSync(rigPath) || !basePath || !fs.existsSync(basePath)) throw new NotFound('нет файла рига или модели');
const dir = path.join(COMFY_OUTPUT, RIG_DIR);
const stamp = Date.now();
const stem = safeName(path.basename(p.rig.glb.filename, '.glb'));
const jobs = motions.map((m, k) => ({
motion: path.join(MOTIONS_DIR, m),
soma: path.join(dir, `${stem}_soma30_${stamp}_${k}.glb`),
output: path.join(dir, `${stem}_retarget_${stamp}_${k}.glb`),
}));
const out: FileRef = { filename: `${stem}_anim_${stamp}.glb`, subfolder: RIG_DIR, type: 'output' };
// имя анимации в GLB — имя файла движения (Walking65.glb → Walking65)
const animName = (m: string) => path.basename(m, path.extname(m));
// скелет — в топологию, которую принимает retarget-generated (руки на ключицах, носки у ног)
const fixed = normalizeHumanoid(fs.readFileSync(rigPath));
const humanoid = fixed ? path.join(dir, `${stem}_humanoid_${stamp}.glb`) : rigPath;
try {
if (fixed) fs.writeFileSync(humanoid, fixed.glb);
await runRetarget({ rig: humanoid, jobs });
const named = jobs.map((j, k) => ({ glb: fs.readFileSync(j.output), name: animName(motions[k]) }));
fs.writeFileSync(path.join(dir, out.filename), graftSkin(fs.readFileSync(basePath), fs.readFileSync(jobs[0].output), undefined, named));
addProjectAnim(projectId, {
glb: out, motion: motions.join(', '), motions, seconds: Math.round((Date.now() - stamp) / 100) / 10, updated: Date.now(),
});
} catch (e) {
addProjectAnim(projectId, null, (e as Error).message);
} finally {
for (const j of jobs) { fs.rmSync(j.soma, { force: true }); fs.rmSync(j.output, { force: true }); }
if (fixed) fs.rmSync(humanoid, { force: true });
}
send(res, 200, withLiveProject(projectOrThrow(projectId)));
}
/** Убрать одну анимацию из проекта (?file=имя GLB). */
function deleteProjectAnim(res: Res, id: number, file: string) {
if (!projectOrThrow(id).anims.some(a => a.glb.filename === file)) throw new NotFound('нет такой анимации');
removeProjectAnim(id, file);
send(res, 200, withLiveProject(projectOrThrow(id)));
}
/** Убрать риг из проекта. */
function removeProjectRig(res: Res, id: number) {
const p = projectOrThrow(id);
if (p.rigJobId) throw new Conflict('идёт риггинг');
clearProjectRig(id);
send(res, 200, withLiveProject(projectOrThrow(id)));
}
/** /**
* Очистка исходника проекта: удаление фона BiRefNet, кадрирование по маске в квадрат с отступом, заливка фона. * Очистка исходника проекта: удаление фона BiRefNet, кадрирование по маске в квадрат с отступом, заливка фона.
* Результат заменяет прежнюю очищенную картинку проекта; история очисток не хранится. * Результат заменяет прежнюю очищенную картинку проекта; история очисток не хранится.
@@ -443,7 +597,7 @@ http.createServer(async (req, res) => {
} }
if (route === 'GET /api/projects') return send(res, 200, listProjects().map(withLiveProject)); if (route === 'GET /api/projects') return send(res, 200, listProjects().map(withLiveProject));
if (route === 'POST /api/projects') return await createProject(req, res); if (route === 'POST /api/projects') return await createProject(req, res);
const pm = /^\/api\/projects\/(\d+)(?:\/(cleanup|clean|model|texture))?$/.exec(u.pathname); const pm = /^\/api\/projects\/(\d+)(?:\/(cleanup|clean|model|texture|rig|retarget))?$/.exec(u.pathname);
if (pm && req.method === 'GET' && !pm[2]) return send(res, 200, withLiveProject(projectOrThrow(Number(pm[1])))); if (pm && req.method === 'GET' && !pm[2]) return send(res, 200, withLiveProject(projectOrThrow(Number(pm[1]))));
if (pm && req.method === 'POST' && pm[2] === 'cleanup') return await projectCleanup(req, res, Number(pm[1])); if (pm && req.method === 'POST' && pm[2] === 'cleanup') return await projectCleanup(req, res, Number(pm[1]));
if (pm && req.method === 'DELETE' && pm[2] === 'clean') return removeProjectClean(res, Number(pm[1])); if (pm && req.method === 'DELETE' && pm[2] === 'clean') return removeProjectClean(res, Number(pm[1]));
@@ -451,10 +605,15 @@ http.createServer(async (req, res) => {
if (pm && req.method === 'DELETE' && pm[2] === 'model') return removeProjectModel(res, Number(pm[1])); if (pm && req.method === 'DELETE' && pm[2] === 'model') return removeProjectModel(res, Number(pm[1]));
if (pm && req.method === 'POST' && pm[2] === 'texture') return await projectTexture(req, res, Number(pm[1])); if (pm && req.method === 'POST' && pm[2] === 'texture') return await projectTexture(req, res, Number(pm[1]));
if (pm && req.method === 'DELETE' && pm[2] === 'texture') return removeProjectTexture(res, Number(pm[1])); if (pm && req.method === 'DELETE' && pm[2] === 'texture') return removeProjectTexture(res, Number(pm[1]));
if (pm && req.method === 'POST' && pm[2] === 'rig') return await projectRig(req, res, Number(pm[1]));
if (pm && req.method === 'DELETE' && pm[2] === 'rig') return removeProjectRig(res, Number(pm[1]));
if (pm && req.method === 'POST' && pm[2] === 'retarget') return await projectRetarget(req, res, Number(pm[1]));
if (pm && req.method === 'DELETE' && pm[2] === 'retarget') return deleteProjectAnim(res, Number(pm[1]), u.searchParams.get('file') ?? '');
if (req.method === 'GET' && u.pathname.startsWith('/api/settings/')) return send(res, 200, getSettings(settingsPage(tail('/api/settings/')))); if (req.method === 'GET' && u.pathname.startsWith('/api/settings/')) return send(res, 200, getSettings(settingsPage(tail('/api/settings/'))));
if (req.method === 'PUT' && u.pathname.startsWith('/api/settings/')) return await saveSettings(req, res, settingsPage(tail('/api/settings/'))); if (req.method === 'PUT' && u.pathname.startsWith('/api/settings/')) return await saveSettings(req, res, settingsPage(tail('/api/settings/')));
if (route === 'POST /api/import') return await importOld(req, res); if (route === 'POST /api/import') return await importOld(req, res);
if (route === 'GET /api/queue') return await queue(res); if (route === 'GET /api/queue') return await queue(res);
if (route === 'GET /api/motions') return send(res, 200, listMotions());
if (route === 'GET /api/image') return await image(res, u.searchParams); if (route === 'GET /api/image') return await image(res, u.searchParams);
if (route === 'GET /api/glb-flat') return glbFlat(res, u.searchParams); if (route === 'GET /api/glb-flat') return glbFlat(res, u.searchParams);
if (u.pathname.startsWith('/api/')) return send(res, 404, { error: 'not found' }); if (u.pathname.startsWith('/api/')) return send(res, 404, { error: 'not found' });
+85
View File
@@ -0,0 +1,85 @@
// Риггинг SkinTokens: skin-tokens.cpp (CPU), собранный в WSL2, запускается как отдельный процесс wsl.exe.
// Задача не проходит через ComfyUI: одна за раз, состояние — в проекте (rigJobId), результат пишется по завершении.
import { spawn } from 'node:child_process';
import path from 'node:path';
/** Дистрибутив WSL и папка skin-tokens.cpp в нём (dist — установка cmake, models — веса GGUF F16). */
const DISTRO = process.env.SKINTOKENS_DISTRO ?? 'Ubuntu';
const ROOT = process.env.SKINTOKENS_ROOT ?? '/home/anton/skin-tokens.cpp';
const CLI = ROOT + '/dist/bin/skintokens-cli';
const MODEL_DIR = ROOT + '/models/SkinTokens-GGUF/F16';
/** Windows-путь → путь в WSL: D:\a\b.glb → /mnt/d/a/b.glb. */
export function toWsl(p: string): string {
const m = /^([A-Za-z]):[\\/](.*)$/.exec(p);
if (!m) throw new Error('не абсолютный путь Windows: ' + p);
return `/mnt/${m[1].toLowerCase()}/${m[2].replace(/\\/g, '/')}`;
}
export type Fit = 'none' | 'global' | 'articulated';
export interface RigRun {
/** меш (Windows-путь) */
mesh: string;
/** готовый скелет (GLB) — тогда только веса под него (skin); без него скелет генерируется (rig) */
skeleton: string | null;
fit: Fit;
postprocess: boolean;
output: string;
}
let busy = false;
/** Идёт риггинг (любого проекта): CPU занят полностью, поэтому по одному. */
export const rigBusy = () => busy;
/** Процесс до завершения; ошибка — с последними строками его вывода. */
function run(cmd: string, args: string[], what: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
// PYTHONIOENCODING: без него print() конвертера падает на кириллице и «→» в кодировке консоли Windows (cp1251)
const child = spawn(cmd, args, { windowsHide: true, env: { ...process.env, PYTHONIOENCODING: 'utf-8' } });
let log = '';
const keep = (d: Buffer) => { log = (log + d.toString('utf8')).slice(-4000); };
child.stdout.on('data', keep);
child.stderr.on('data', keep);
child.on('error', reject);
child.on('close', code => {
if (code === 0) resolve();
else reject(new Error(`${what}: код ${code}: ${log.replace(/\0/g, '').trim().split('\n').slice(-3).join(' ')}`));
});
});
}
const cli = (args: string[]) => run('wsl.exe', ['-d', DISTRO, '--', CLI, ...args], 'skintokens-cli');
/** skintokens-cli rig|skin … --device cpu. */
export async function runSkinTokens(r: RigRun): Promise<void> {
const args = r.skeleton
? ['skin', MODEL_DIR, toWsl(r.mesh), toWsl(r.skeleton), toWsl(r.output), '--fit', r.fit]
: ['rig', MODEL_DIR, toWsl(r.mesh), toWsl(r.output)];
args.push('--device', 'cpu');
if (r.postprocess) args.push('--postprocess');
busy = true;
try {
await cli(args);
} finally {
busy = false;
}
}
/** Python с numpy и pygltflib (встроенный в ComfyUI) и конвертер Mixamo → SOMA30 (img2glb/tools). */
const PYTHON = process.env.IMG2GLB_PYTHON ?? 'D:\\AI\\ComfyUI-0.37.0\\python_embeded\\python.exe';
const MIXAMO2SOMA = path.join(import.meta.dirname, '..', '..', 'tools', 'mixamo2soma30.py');
/**
* Анимации Mixamo на сгенерированный SkinTokens скелет (без повторного риггинга, секунды): каждое движение → SOMA30
* с позой покоя под руки этого скелета (mixamo2soma30.py --match), затем все skintokens-cli retarget-generated
* одним запуском WSL (сам запуск WSL — основная часть времени).
*/
export async function runRetarget(o: { rig: string; jobs: { motion: string; soma: string; output: string }[] }): Promise<void> {
for (const j of o.jobs) await run(PYTHON, [MIXAMO2SOMA, j.motion, j.soma, '--match', o.rig], 'mixamo2soma30 ' + path.basename(j.motion));
const script = 'set -e; cli="$1"; rig="$2"; shift 2; while [ $# -gt 0 ]; do "$cli" retarget-generated "$rig" "$1" "$2"; shift 2; done';
// --exec: без промежуточной оболочки — иначе wsl.exe склеивает аргументы в строку и $1… раскрываются заранее
await run('wsl.exe', ['-d', DISTRO, '--exec', 'bash', '-c', script, 'retarget', CLI, toWsl(o.rig),
...o.jobs.flatMap(j => [toWsl(j.soma), toWsl(j.output)])], 'skintokens-cli retarget-generated');
}
+47 -1
View File
@@ -70,6 +70,52 @@ function meshPreview(mesh: Link, prefix: string, id: number): Graph {
}; };
} }
/**
* Очищенная картинка → 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, градусы поворота вокруг вертикали. */ /** Ракурсы рендеров текстурированной модели TRELLIS.2, градусы поворота вокруг вертикали. */
export const TEXTURE_VIEWS = [0, 90, 180, 270]; export const TEXTURE_VIEWS = [0, 90, 180, 270];
@@ -192,7 +238,7 @@ export function paintGraph(o: { image: string; mesh: string; prefix: string; see
export function kindOf(graph: unknown): JobKind { export function kindOf(graph: unknown): JobKind {
const types = Object.values((graph ?? {}) as Graph).map(n => n?.class_type); const types = Object.values((graph ?? {}) as Graph).map(n => n?.class_type);
if (types.includes('Img2GlbHunyuanPaint')) return 'текстура'; if (types.includes('Img2GlbHunyuanPaint')) return 'текстура';
if (types.includes('SaveGLB')) return '3D-модель'; if (types.includes('SaveGLB') || types.includes('Hy3DExportMesh')) return '3D-модель';
if (types.includes('RemoveBackground')) return 'очистка фона'; if (types.includes('RemoveBackground')) return 'очистка фона';
if (types.includes('KSampler') && types.includes('CheckpointLoaderSimple')) return 'генерация'; if (types.includes('KSampler') && types.includes('CheckpointLoaderSimple')) return 'генерация';
return 'другое'; return 'другое';
+74 -7
View File
@@ -31,10 +31,10 @@ export interface Text2ImgInput {
*/ */
export type BatchState = 'draft' | 'pending' | 'done' | 'error'; export type BatchState = 'draft' | 'pending' | 'done' | 'error';
/** Картинка батча (Images): id и проект, если из неё создан проект. */ /** Картинка батча (Images): id и проекты, созданные из неё (из одной картинки — сколько угодно). */
export interface BatchImage extends FileRef { export interface BatchImage extends FileRef {
id: number; id: number;
projectId: number | null; projectIds: number[];
} }
/** Проект — работа над одной картинкой из батча. */ /** Проект — работа над одной картинкой из батча. */
@@ -72,12 +72,57 @@ export interface Project {
textureJobId: string | null; textureJobId: string | null;
textureError?: string; textureError?: string;
textureLive?: LiveStatus; textureLive?: LiveStatus;
/** риг SkinTokens: скелет и веса скиннинга на модели (текстурированной, если есть) */
rig: RigArtifacts | null;
/** идёт риггинг: 'skintokens:<мс начала>' (в ComfyUI не попадает — skin-tokens.cpp в WSL) */
rigJobId: string | null;
rigError?: string;
/** анимации, перенесённые на сгенерированный SkinTokens скелет рига (retarget-generated), по порядку создания */
anims: AnimArtifacts[];
animError?: string;
} }
/** Чем сгенерирована 3D-модель. */ /** Анимация из app/data/motions на скелете рига: модель со скелетом рига и движением. */
export type ModelGenerator = 'hunyuan' | 'trellis'; export interface AnimArtifacts {
glb: FileRef;
/** файлы движений через запятую (пути в app/data/motions) — подпись */
motion: string;
/** файлы движений по порядку анимаций в GLB (у старых записей нет — тогда одна: motion) */
motions?: string[];
/** длительность ретаргета, с */
seconds: number;
updated: number;
}
export const GENERATOR_NAMES: Record<ModelGenerator, string> = { hunyuan: 'Hunyuan3D-2 mini', trellis: 'TRELLIS.2' }; /** Риг: rig — скелет сгенерирован SkinTokens, skin — только веса под скелет пользователя. */
export interface RigArtifacts {
mode: 'rig' | 'skin';
glb: FileRef;
/** скелет пользователя (GLB), если был */
skeleton: FileRef | null;
/** скелет из app/data/motions (относительный путь), если был — его анимация есть в GLB */
motion?: string;
/** на какой модели: текстурированной или без текстур */
basedOn: 'textured' | 'model';
faces: number | null;
joints: number;
/** время работы SkinTokens, с */
seconds: number;
updated: number;
}
/** Начало идущего риггинга (мс) из rigJobId. */
export const rigStarted = (jobId: string | null) => (jobId?.startsWith('skintokens:') ? Number(jobId.slice(11)) : null);
/** Чем сгенерирована 3D-модель. */
export type ModelGenerator = 'hunyuan' | 'hy3dwrapper' | 'trellis';
export const GENERATOR_NAMES: Record<ModelGenerator, string> = {
hunyuan: 'Hunyuan3D-2 mini', hy3dwrapper: 'Hunyuan3D-2 mini (Hy3D wrapper)', trellis: 'TRELLIS.2',
};
/** Модель без текстур — её можно текстурировать Hunyuan3D-2 Paint (TRELLIS.2 даёт текстуры сразу). */
export const isUntextured = (g: ModelGenerator) => g !== 'trellis';
/** Модель без текстур. */ /** Модель без текстур. */
export interface ModelArtifacts { export interface ModelArtifacts {
@@ -123,6 +168,9 @@ export interface ProjectModelRequest {
octree: number | string; octree: number | string;
/** целевое число треугольников после упрощения */ /** целевое число треугольников после упрощения */
faces: number | string; faces: number | string;
/** Hy3D wrapper: перестроить сетку Instant Meshes (Hy3DIMRemesh) до remeshVertices вершин */
remesh: boolean;
remeshVertices: number | string;
/** TRELLIS.2: размер текстуры, px */ /** TRELLIS.2: размер текстуры, px */
textureSize: number | string; textureSize: number | string;
/** TRELLIS.2: металличность и шероховатость (карты metallic, roughness) в модели */ /** TRELLIS.2: металличность и шероховатость (карты metallic, roughness) в модели */
@@ -175,7 +223,7 @@ export type ImageBatch = TextBatch | UploadBatch;
/** В батче есть картинка, из которой создан проект. */ /** В батче есть картинка, из которой создан проект. */
export function hasProject(b: ImageBatch): boolean { export function hasProject(b: ImageBatch): boolean {
return b.images.some(i => i.projectId != null); return b.images.some(i => i.projectIds.length > 0);
} }
export function isLocked(b: ImageBatch): boolean { export function isLocked(b: ImageBatch): boolean {
@@ -230,6 +278,8 @@ export interface ModelParams {
/** Hunyuan3D-2 mini */ /** Hunyuan3D-2 mini */
steps?: number; steps?: number;
octree?: number; octree?: number;
/** Hy3D wrapper: Hy3DIMRemesh, 0 — выключен */
remeshVertices?: number;
/** TRELLIS.2 */ /** TRELLIS.2 */
textureSize?: number; textureSize?: number;
pbr?: boolean; pbr?: boolean;
@@ -292,6 +342,9 @@ export interface ModelSettings {
steps: string; steps: string;
octree: string; octree: string;
faces: string; faces: string;
/** Hy3D wrapper */
remesh: boolean;
remeshVertices: string;
/** TRELLIS.2 */ /** TRELLIS.2 */
trellisFaces: string; trellisFaces: string;
textureSize: string; textureSize: string;
@@ -304,8 +357,22 @@ export interface TextureSettings {
delight: boolean; delight: boolean;
} }
export interface RetargetSettings {
/** выбранные файлы движений в app/data/motions */
motions: string[];
}
export interface RigSettings {
/** скелет: '' — сгенерировать, 'file' — свой GLB, иначе путь GLB в app/data/motions */
skeleton: string;
/** как совместить скелет пользователя с мешем: none | global | articulated */
fit: string;
postprocess: boolean;
}
export type SettingsByPage = { export type SettingsByPage = {
text2img: Text2ImgSettings; cleanup: CleanupSettings; model: ModelSettings; texture: TextureSettings; text2img: Text2ImgSettings; cleanup: CleanupSettings; model: ModelSettings; texture: TextureSettings; rig: RigSettings;
retarget: RetargetSettings;
}; };
export type SettingsPage = keyof SettingsByPage; export type SettingsPage = keyof SettingsByPage;
+1 -1
View File
@@ -49,7 +49,7 @@ export function BatchCard({ batch: b }: { batch: ImageBatch }) {
<Link to={'/batch/' + b.id} className={'run run-link' + (hasProject(b) ? ' in-project' : '')} title={tooltip}> <Link to={'/batch/' + b.id} className={'run run-link' + (hasProject(b) ? ' in-project' : '')} title={tooltip}>
<div className="strip"> <div className="strip">
{b.images.map(i => ( {b.images.map(i => (
<div key={i.id} className={'th' + (i.projectId != null ? ' in-project' : '')}> <div key={i.id} className={'th' + (i.projectIds.length ? ' in-project' : '')}>
<img src={thumbUrl(i)} alt={i.filename} loading="lazy" /> <img src={thumbUrl(i)} alt={i.filename} loading="lazy" />
</div> </div>
))} ))}
+36 -6
View File
@@ -8,7 +8,8 @@ declare module 'react' {
interface IntrinsicElements { interface IntrinsicElements {
'model-viewer': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & { 'model-viewer': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement> & {
src?: string; alt?: string; loading?: string; 'camera-controls'?: string; 'camera-orbit'?: string; src?: string; alt?: string; loading?: string; 'camera-controls'?: string; 'camera-orbit'?: string;
'shadow-intensity'?: string; 'environment-image'?: string; exposure?: string; 'shadow-intensity'?: string; 'environment-image'?: string; exposure?: string; autoplay?: boolean;
'animation-name'?: string;
}; };
} }
} }
@@ -34,16 +35,21 @@ export function Seg<T extends string>({ options, value, onChange }: {
} }
/** /**
* Готовая модель: интерактивный GLB с переключателем «сглаживание / без». * Готовая модель: интерактивный GLB с переключателем «сглаживание / без»; для рига (bones) — ещё «скелет» (включён):
* кости линиями и суставы точками поверх модели (модуль skeleton.ts грузится при первом включении).
* Пока GLB грузится (или если не загрузился) — вместо 3D показывается превью; что показывать вообще * Пока GLB грузится (или если не загрузился) — вместо 3D показывается превью; что показывать вообще
* (модель или форму) решает вызывающий по записи в базе, а не по загрузке model-viewer. * (модель или форму) решает вызывающий по записи в базе, а не по загрузке model-viewer.
*/ */
export function ModelView({ glb, preview, onOpen }: { export function ModelView({ glb, preview, onOpen, bones }: {
glb: FileRef; preview?: FileRef | null; onOpen: (src: string, caption: string) => void; glb: FileRef; preview?: FileRef | null; onOpen: (src: string, caption: string) => void; bones?: boolean;
}) { }) {
const [ready, setReady] = useState(false); // скрипт model-viewer загружен const [ready, setReady] = useState(false); // скрипт model-viewer загружен
const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading'); // загрузка GLB const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading'); // загрузка GLB
const [smooth, setSmooth] = useState(true); // «без сглаживания» — тот же GLB без нормалей (плоские грани) const [smooth, setSmooth] = useState(true); // «без сглаживания» — тот же GLB без нормалей (плоские грани)
const [skeleton, setSkeleton] = useState(!!bones);
// анимации в GLB (для переключателя, если их больше одной) и выбранная
const [anims, setAnims] = useState<string[]>([]);
const [anim, setAnim] = useState('');
const ref = useRef<HTMLElement>(null); const ref = useRef<HTMLElement>(null);
const src = smooth ? fileUrl(glb) const src = smooth ? fileUrl(glb)
: '/api/glb-flat?' + new URLSearchParams({ filename: glb.filename, subfolder: glb.subfolder }); : '/api/glb-flat?' + new URLSearchParams({ filename: glb.filename, subfolder: glb.subfolder });
@@ -59,13 +65,28 @@ export function ModelView({ glb, preview, onOpen }: {
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
const onLoad = () => setState('loaded'); const onLoad = () => {
setState('loaded');
const list = ((el as unknown as { availableAnimations?: string[] }).availableAnimations ?? []).slice();
setAnims(list);
setAnim(a => (list.includes(a) ? a : list[0] ?? ''));
};
const onError = () => setState('error'); const onError = () => setState('error');
el.addEventListener('load', onLoad); el.addEventListener('load', onLoad);
el.addEventListener('error', onError); el.addEventListener('error', onError);
return () => { el.removeEventListener('load', onLoad); el.removeEventListener('error', onError); }; return () => { el.removeEventListener('load', onLoad); el.removeEventListener('error', onError); };
}, [ready, src]); }, [ready, src]);
// скелет — после загрузки GLB (при смене src модель новая, скелет строится заново)
useEffect(() => {
const el = ref.current;
if (!skeleton || state !== 'loaded' || !el) return;
let remove: (() => void) | null = null;
let alive = true;
void import('./skeleton.ts').then(m => { if (alive) remove = m.showSkeleton(el); });
return () => { alive = false; remove?.(); };
}, [skeleton, state, src]);
const previewImg = preview && ( const previewImg = preview && (
<img className="mv-layer" src={fileUrl(preview)} alt={preview.filename} onClick={() => onOpen(fileUrl(preview), preview.filename)} /> <img className="mv-layer" src={fileUrl(preview)} alt={preview.filename} onClick={() => onOpen(fileUrl(preview), preview.filename)} />
); );
@@ -73,7 +94,8 @@ export function ModelView({ glb, preview, onOpen }: {
return ( return (
<div className="mv-wrap"> <div className="mv-wrap">
{ready && ( {ready && (
<model-viewer ref={ref} src={src} alt={glb.filename} loading="eager" camera-controls="" camera-orbit="35deg 80deg auto" <model-viewer ref={ref} src={src} alt={glb.filename} loading="eager" camera-controls="" autoplay
{...(anim ? { 'animation-name': anim } : {})} camera-orbit="35deg 80deg auto"
shadow-intensity="1" environment-image="neutral" exposure="0.8" shadow-intensity="1" environment-image="neutral" exposure="0.8"
style={{ visibility: state === 'loaded' ? 'visible' : 'hidden' }} /> style={{ visibility: state === 'loaded' ? 'visible' : 'hidden' }} />
)} )}
@@ -82,6 +104,14 @@ export function ModelView({ glb, preview, onOpen }: {
<span className="mv-seg"> <span className="mv-seg">
<Seg options={[['smooth', 'сглаживание'], ['flat', 'без']]} value={smooth ? 'smooth' : 'flat'} <Seg options={[['smooth', 'сглаживание'], ['flat', 'без']]} value={smooth ? 'smooth' : 'flat'}
onChange={v => setSmooth(v === 'smooth')} /> onChange={v => setSmooth(v === 'smooth')} />
{bones && (
<Seg options={[['on', 'скелет']]} value={skeleton ? 'on' : ''} onChange={() => setSkeleton(x => !x)} />
)}
{anims.length > 1 && (
<select className="mv-anim" value={anim} onChange={e => setAnim(e.target.value)} onClick={e => e.stopPropagation()}>
{anims.map(a => <option key={a}>{a}</option>)}
</select>
)}
</span> </span>
</div> </div>
); );
+50
View File
@@ -0,0 +1,50 @@
// Скелет поверх модели прямо в <model-viewer>: кости — линии (THREE.SkeletonHelper), суставы — точки.
// Сцена берётся через внутренний символ model-viewer 'scene' (не публичное API; проверено на @google/model-viewer 4.3):
// если символа нет, скелет просто не показывается. three.js — тот же экземпляр, что внутри model-viewer (Vite).
import {
AdditiveBlending, Bone, BufferAttribute, BufferGeometry, Object3D, Points, PointsMaterial, SkeletonHelper, Vector3,
} from 'three';
interface ModelScene extends Object3D { queueRender?: () => void }
function sceneOf(mv: HTMLElement): ModelScene | null {
const sym = Object.getOwnPropertySymbols(mv).find(s => s.description === 'scene');
return sym ? ((mv as unknown as Record<symbol, ModelScene>)[sym] ?? null) : null;
}
/** Показать скелет модели; возвращает функцию, убирающую его (null — костей нет или сцена недоступна). */
export function showSkeleton(mv: HTMLElement): (() => void) | null {
const scene = sceneOf(mv);
if (!scene) return null;
const lines = new SkeletonHelper(scene); // сцена model-viewer — корень с единичной матрицей: позиции костей мировые
if (!lines.bones.length) return null;
const mat = lines.material as unknown as { depthTest: boolean; transparent: boolean; linewidth: number };
mat.depthTest = false; // поверх меша
mat.transparent = true;
lines.renderOrder = 999;
// суставы: точки постоянного размера на экране, позиции — каждый кадр из матриц костей
const bones: Bone[] = lines.bones;
const pos = new BufferAttribute(new Float32Array(bones.length * 3), 3);
const geometry = new BufferGeometry().setAttribute('position', pos);
const joints = new Points(geometry, new PointsMaterial({
color: 0xffd400, size: 7, sizeAttenuation: false, depthTest: false, transparent: true, blending: AdditiveBlending,
}));
joints.renderOrder = 1000;
joints.frustumCulled = false;
const v = new Vector3();
joints.onBeforeRender = () => {
bones.forEach((b, i) => { v.setFromMatrixPosition(b.matrixWorld); pos.setXYZ(i, v.x, v.y, v.z); });
pos.needsUpdate = true;
};
scene.add(lines, joints);
scene.queueRender?.();
return () => {
scene.remove(lines, joints);
lines.dispose();
geometry.dispose();
(joints.material as PointsMaterial).dispose();
scene.queueRender?.();
};
}
+16 -2
View File
@@ -37,7 +37,21 @@ export const projectApi = {
texture: (id: number, params: ProjectTextureRequest) => api<Project>(`/api/projects/${id}/texture`, { body: params }), texture: (id: number, params: ProjectTextureRequest) => api<Project>(`/api/projects/${id}/texture`, { body: params }),
/** Удалить текстурную группу (у TRELLIS.2 — вместе с моделью). */ /** Удалить текстурную группу (у TRELLIS.2 — вместе с моделью). */
removeTexture: (id: number) => api<Project>(`/api/projects/${id}/texture`, { method: 'DELETE' }), removeTexture: (id: number) => api<Project>(`/api/projects/${id}/texture`, { method: 'DELETE' }),
/** Проект из картинки: новый или уже созданный из неё. */ /** Риг SkinTokens; skeleton — необязательный GLB со скелетом (тогда только веса под него). */
rig: async (id: number, o: { skeleton: File | null; motion: string; fit: string; postprocess: boolean }) => {
const form = new FormData();
if (o.skeleton) form.append('skeleton', o.skeleton, o.skeleton.name);
else if (o.motion) form.append('motion', o.motion);
form.append('fit', o.fit);
form.append('postprocess', String(o.postprocess));
return parse<Project>(await fetch(`/api/projects/${id}/rig`, { method: 'POST', body: form }));
},
removeRig: (id: number) => api<Project>(`/api/projects/${id}/rig`, { method: 'DELETE' }),
/** Анимация из motions на сгенерированный скелет рига (секунды). */
retarget: (id: number, motions: string[]) => api<Project>(`/api/projects/${id}/retarget`, { body: { motions } }),
removeAnim: (id: number, file: string) =>
api<Project>(`/api/projects/${id}/retarget?` + new URLSearchParams({ file }), { method: 'DELETE' }),
/** Новый проект из картинки (из одной картинки можно сделать несколько). */
forImage: (imageId: number) => api<Project>('/api/projects', { body: { imageId } }), forImage: (imageId: number) => api<Project>('/api/projects', { body: { imageId } }),
get: (id: number) => api<Project>('/api/projects/' + id), get: (id: number) => api<Project>('/api/projects/' + id),
}; };
@@ -88,7 +102,7 @@ export function useProject(id: number | null) {
useEffect(() => { setProject(null); void refresh(); }, [refresh]); useEffect(() => { setProject(null); void refresh(); }, [refresh]);
const working = !!project?.cleanJobId || !!project?.modelJobId || !!project?.textureJobId; const working = !!project?.cleanJobId || !!project?.modelJobId || !!project?.textureJobId || !!project?.rigJobId;
useEffect(() => { useEffect(() => {
if (!working) return; if (!working) return;
const t = setInterval(() => { void refresh(); }, POLL_MS); const t = setInterval(() => { void refresh(); }, POLL_MS);
+7 -5
View File
@@ -9,8 +9,8 @@ import { api, fileUrl } from '../lib/api.ts';
import { batchApi, projectApi, useBatch } from '../lib/batches.ts'; import { batchApi, projectApi, useBatch } from '../lib/batches.ts';
/** /**
* Картинки батча: клик — на весь экран; «в проект» — создать проект из картинки (или перейти в уже созданный); * Картинки батча: клик — на весь экран; «+ проект» — новый проект из картинки (их может быть несколько),
* пока идёт генерация — заглушки. * «#N» — уже созданные проекты; пока идёт генерация — заглушки.
*/ */
function Images({ batch }: { batch: ImageBatch }) { function Images({ batch }: { batch: ImageBatch }) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -37,9 +37,11 @@ function Images({ batch }: { batch: ImageBatch }) {
<div key={i.id} className="cell"> <div key={i.id} className="cell">
<img src={fileUrl(i)} alt={i.filename} onClick={() => viewer.open(fileUrl(i), i.filename)} /> <img src={fileUrl(i)} alt={i.filename} onClick={() => viewer.open(fileUrl(i), i.filename)} />
<div className="links"> <div className="links">
<a href={'/project/' + (i.projectId ?? '')} className={i.projectId ? 'main' : ''} {i.projectIds.map(id => (
title={i.projectId ? `Проект #${i.projectId}` : 'Создать проект из этой картинки'} <Link key={id} to={'/project/' + id} className="main" title={`Проект #${id}`}>#{id}</Link>
onClick={e => { e.preventDefault(); void toProject(i.id); }}>в проект</a> ))}
<a href="#" title="Новый проект из этой картинки"
onClick={e => { e.preventDefault(); void toProject(i.id); }}>+ проект</a>
<a href={fileUrl(i)} download={i.filename}>скачать</a> <a href={fileUrl(i)} download={i.filename}>скачать</a>
</div> </div>
</div> </div>
+156 -17
View File
@@ -1,21 +1,25 @@
import { useState, type FormEvent, type ReactNode } from 'react'; import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { Link, useParams } from 'react-router'; import { Link, useParams } from 'react-router';
import type { import type {
CleanupSettings, FileRef, ModelGenerator, ModelSettings, Project as ProjectT, TextureSettings, TextureSource, CleanupSettings, FileRef, ModelGenerator, ModelSettings, Project as ProjectT, RetargetSettings, RigSettings, TextureSettings,
TextureSource,
} from '../../shared/api.ts'; } from '../../shared/api.ts';
import { GENERATOR_NAMES } from '../../shared/api.ts'; import { GENERATOR_NAMES, isUntextured, rigStarted } from '../../shared/api.ts';
import { ModelView } from '../components/ModelView.tsx'; import { ModelView } from '../components/ModelView.tsx';
import { useViewer } from '../components/Viewer.tsx'; import { useViewer } from '../components/Viewer.tsx';
import { fileUrl } from '../lib/api.ts'; import { api, fileUrl } from '../lib/api.ts';
import { cleanStateText, modelStateText, projectApi, textureStateText, useProject } from '../lib/batches.ts'; import { cleanStateText, modelStateText, projectApi, textureStateText, useProject } from '../lib/batches.ts';
import { useSettings } from '../lib/settings.ts'; import { useSettings } from '../lib/settings.ts';
const CLEAN_DEFAULTS: CleanupSettings = { size: '1024', margin: '154', grow: '0', background: '#ffffff' }; const CLEAN_DEFAULTS: CleanupSettings = { size: '1024', margin: '154', grow: '0', background: '#ffffff' };
const MODEL_DEFAULTS: ModelSettings = { const MODEL_DEFAULTS: ModelSettings = {
generator: 'hunyuan', seed: '0', steps: '30', octree: '380', faces: '5000', trellisFaces: '100000', textureSize: '2048', generator: 'hunyuan', seed: '0', steps: '30', octree: '380', faces: '5000', remesh: false, remeshVertices: '5000',
trellisFaces: '100000', textureSize: '2048',
trellisPbr: false, trellisPbr: false,
}; };
const TEXTURE_DEFAULTS: TextureSettings = { seed: '0', textureSize: '2048', delight: true }; const TEXTURE_DEFAULTS: TextureSettings = { seed: '0', textureSize: '2048', delight: true };
const RIG_DEFAULTS: RigSettings = { skeleton: '', fit: 'global', postprocess: false };
const RETARGET_DEFAULTS: RetargetSettings = { motions: [] };
type Open = (src: string, caption: string) => void; type Open = (src: string, caption: string) => void;
type OnProject = (p: ProjectT) => void; type OnProject = (p: ProjectT) => void;
@@ -115,10 +119,13 @@ function ModelFormBlock({ project, onProject }: { project: ProjectT; onProject:
<FormBox title="3D-модель" busy={busy} error={error || (st?.error ? st.text : '')} <FormBox title="3D-модель" busy={busy} error={error || (st?.error ? st.text : '')}
hint={trellis hint={trellis
? 'TRELLIS.2 (встроенные ноды ComfyUI): модель сразу с текстурами — base color (и metallic, roughness), UV-развёртка, рендеры с 4 сторон.' ? 'TRELLIS.2 (встроенные ноды ComfyUI): модель сразу с текстурами — base color (и metallic, roughness), UV-развёртка, рендеры с 4 сторон.'
: 'Hunyuan3D-2 mini: только геометрия (~1.5–2 мин); текстуры потом — Hunyuan3D-2 Paint.'} : v.generator === 'hy3dwrapper'
? 'Hunyuan3D-2 mini на нодах ComfyUI-Hunyuan3DWrapper: только геометрия, очистка меша, по желанию — Instant Meshes remesh; текстуры потом — Paint.'
: 'Hunyuan3D-2 mini: только геометрия (~1.5–2 мин); текстуры потом — Hunyuan3D-2 Paint.'}
onSubmit={() => void act(() => projectApi.model(project.id, { onSubmit={() => void act(() => projectApi.model(project.id, {
generator: v.generator, seed: v.seed, steps: v.steps, octree: v.octree, generator: v.generator, seed: v.seed, steps: v.steps, octree: v.octree,
faces: trellis ? v.trellisFaces : v.faces, textureSize: v.textureSize, pbr: v.trellisPbr, faces: trellis ? v.trellisFaces : v.faces, textureSize: v.textureSize, pbr: v.trellisPbr,
remesh: v.generator === 'hy3dwrapper' && v.remesh, remeshVertices: v.remeshVertices,
}))}> }))}>
<label>Модель <label>Модель
<select value={v.generator} onChange={e => set('generator', e.target.value as ModelGenerator)}> <select value={v.generator} onChange={e => set('generator', e.target.value as ModelGenerator)}>
@@ -147,9 +154,23 @@ function ModelFormBlock({ project, onProject }: { project: ProjectT; onProject:
<label><span>Детализация <small>— octree 256…512</small></span> <label><span>Детализация <small>— octree 256…512</small></span>
<input type="number" min={64} max={512} step={1} value={v.octree} onChange={e => set('octree', e.target.value)} /> <input type="number" min={64} max={512} step={1} value={v.octree} onChange={e => set('octree', e.target.value)} />
</label> </label>
<label><span>Треугольников <small>— 0 = без упрощения</small></span> <label><span>Треугольников <small>— {v.generator === 'hy3dwrapper' ? 'максимум после очистки' : '0 = без упрощения'}</small></span>
<input type="number" min={0} max={2000000} step={500} value={v.faces} onChange={e => set('faces', e.target.value)} /> <input type="number" min={0} max={2000000} step={100} value={v.faces} onChange={e => set('faces', e.target.value)} />
</label> </label>
{v.generator === 'hy3dwrapper' && (
<>
<label className="check">
<input type="checkbox" checked={v.remesh} onChange={e => set('remesh', e.target.checked)} />
<span>IM Remesh <small>— перестроить сетку (Instant Meshes)</small></span>
</label>
{v.remesh && (
<label><span>Вершин Instant Meshes <small>— потом дыры заделываются и меш упрощается до числа треугольников</small></span>
<input type="number" min={100} max={2000000} step={100} value={v.remeshVertices}
onChange={e => set('remeshVertices', e.target.value)} />
</label>
)}
</>
)}
</> </>
)} )}
</FormBox> </FormBox>
@@ -190,13 +211,18 @@ function PaintFormBlock({ project, onProject }: { project: ProjectT; onProject:
} }
/** Карточки 3D-артефактов; group — что удаляется вместе с карточкой. */ /** Карточки 3D-артефактов; group — что удаляется вместе с карточкой. */
type CardId = 'model' | 'textured' | 'baseColor' | 'metallic' | 'roughness' | 'uv' | 'views'; /** anim:<имя GLB> — карточка одной анимации. */
type CardId = 'model' | 'textured' | 'baseColor' | 'metallic' | 'roughness' | 'uv' | 'views' | 'rig' | `anim:${string}`;
const CARD_NAMES: Record<CardId, string> = { const CARD_NAMES: Record<Exclude<CardId, `anim:${string}`>, string> = {
model: 'модель без текстур', textured: 'текстурированная модель', baseColor: 'base color', metallic: 'metallic', model: 'модель без текстур', textured: 'текстурированная модель', baseColor: 'base color', metallic: 'metallic',
roughness: 'roughness', uv: 'UV-развёртка', views: 'виды', roughness: 'roughness', uv: 'UV-развёртка', views: 'виды', rig: 'риг',
}; };
const isAnim = (c: CardId): c is `anim:${string}` => c.startsWith('anim:');
const animOf = (p: ProjectT, c: CardId) => p.anims.find(a => 'anim:' + a.glb.filename === c);
const cardName = (p: ProjectT, c: CardId) => (isAnim(c) ? 'анимация ' + (animOf(p, c)?.motion ?? '') : CARD_NAMES[c]);
/** Какие карточки есть у проекта. */ /** Какие карточки есть у проекта. */
function presentCards(p: ProjectT): CardId[] { function presentCards(p: ProjectT): CardId[] {
const t = p.texture; const t = p.texture;
@@ -206,6 +232,8 @@ function presentCards(p: ProjectT): CardId[] {
for (const k of ['baseColor', 'metallic', 'roughness', 'uv'] as const) if (t[k]) cards.push(k); for (const k of ['baseColor', 'metallic', 'roughness', 'uv'] as const) if (t[k]) cards.push(k);
if (t.views.length) cards.push('views'); if (t.views.length) cards.push('views');
} }
if (p.rig) cards.push('rig');
for (const a of p.anims) cards.push(`anim:${a.glb.filename}`);
return cards; return cards;
} }
@@ -215,6 +243,8 @@ function presentCards(p: ProjectT): CardId[] {
*/ */
function removedWith(p: ProjectT, card: CardId): CardId[] { function removedWith(p: ProjectT, card: CardId): CardId[] {
const all = presentCards(p); const all = presentCards(p);
if (isAnim(card)) return [card];
if (card === 'rig') return all.filter(c => c === 'rig' || isAnim(c));
if (card === 'model' || p.texture?.source === 'trellis') return all; if (card === 'model' || p.texture?.source === 'trellis') return all;
return all.filter(c => c !== 'model'); return all.filter(c => c !== 'model');
} }
@@ -227,13 +257,14 @@ function ArtifactCard({ project, card, title, onProject, children, download, dat
const { busy, error, act } = useAct(onProject); const { busy, error, act } = useAct(onProject);
const removed = removedWith(project, card); const removed = removedWith(project, card);
const remove = () => { const remove = () => {
const others = removed.filter(c => c !== card).map(c => CARD_NAMES[c]); const others = removed.filter(c => c !== card).map(c => cardName(project, c));
const msg = `Удалить «${CARD_NAMES[card]}» из проекта?` + (others.length ? `\nВместе с ней будут удалены: ${others.join(', ')}.` : '') const msg = `Удалить «${cardName(project, card)}» из проекта?` + (others.length ? `\nВместе с ней будут удалены: ${others.join(', ')}.` : '')
+ '\nФайлы в ComfyUI/output останутся.'; + '\nФайлы в ComfyUI/output останутся.';
if (busy || !confirm(msg)) return; if (busy || !confirm(msg)) return;
void act(() => (removed.includes('model') ? projectApi.removeModel(project.id) : projectApi.removeTexture(project.id))); void act(() => (isAnim(card) ? projectApi.removeAnim(project.id, card.slice(5)) : card === 'rig' ? projectApi.removeRig(project.id)
: removed.includes('model') ? projectApi.removeModel(project.id) : projectApi.removeTexture(project.id)));
}; };
const working = !!project.modelJobId || !!project.textureJobId; const working = !!project.modelJobId || !!project.textureJobId || !!project.rigJobId;
return ( return (
<div className="pane"> <div className="pane">
<div className="pane-title"><span title={title}>{title}</span></div> <div className="pane-title"><span title={title}>{title}</span></div>
@@ -257,6 +288,92 @@ function Picture({ file, open }: { file: FileRef; open: Open }) {
return <img src={fileUrl(file)} alt={file.filename} onClick={() => open(fileUrl(file), file.filename)} />; return <img src={fileUrl(file)} alt={file.filename} onClick={() => open(fileUrl(file), file.filename)} />;
} }
/**
* Форма ретаргета: движение из app/data/motions (Mixamo) на скелет, сгенерированный SkinTokens, — без повторного
* риггинга, секунды. Показывается только при риге со сгенерированным скелетом.
*/
function RetargetFormBlock({ project, onProject }: { project: ProjectT; onProject: OnProject }) {
const { values: v, set } = useSettings('retarget', RETARGET_DEFAULTS);
const { busy, error, act } = useAct(onProject);
const [motions, setMotions] = useState<string[]>([]);
useEffect(() => { api<string[]>('/api/motions').then(setMotions).catch(() => setMotions([])); }, []);
const chosen = (v.motions ?? []).filter(m => motions.includes(m));
const toggle = (m: string, on: boolean) => set('motions', on ? [...chosen, m] : chosen.filter(x => x !== m));
const submit = () => {
// вторая и следующие карточки — отдельный ретаргет: предупредить
if (project.anims.length && !confirm('Ретаргетинг будет выполнен повторно — для новой карточки отдельно от уже '
+ 'созданных. Скелет и движения в ней могут получиться не такими, как в других карточках анимации. Продолжить?')) return;
void act(() => projectApi.retarget(project.id, chosen));
};
return (
<FormBox title="Анимация" busy={busy || !chosen.length} error={error || (project.animError ? 'ошибка: ' + project.animError : '')}
hint="Движения Mixamo из motions/ переносятся на скелет рига (SOMA30 → retarget-generated) без повторного риггинга; несколько выбранных — одна карточка, один GLB с переключением."
onSubmit={submit}>
<fieldset className="checks">
{motions.map(m => (
<label key={m} className="check">
<input type="checkbox" checked={chosen.includes(m)} onChange={e => toggle(m, e.target.checked)} />
<span>motions/{m}</span>
</label>
))}
</fieldset>
{busy && <small>ретаргет {chosen.length > 1 ? `${chosen.length} движений` : ''}…</small>}
</FormBox>
);
}
/**
* Форма рига SkinTokens (skin-tokens.cpp на CPU в WSL). Скелет (GLB) необязателен: без него SkinTokens генерирует
* скелет сам (экспериментально), с ним — только веса под этот скелет. Идёт риггинг — сколько уже длится.
*/
function RigFormBlock({ project, onProject }: { project: ProjectT; onProject: OnProject }) {
const { values: v, set } = useSettings('rig', RIG_DEFAULTS);
const { busy, error, act } = useAct(onProject);
const [skeleton, setSkeleton] = useState<File | null>(null);
const [motions, setMotions] = useState<string[]>([]);
useEffect(() => { api<string[]>('/api/motions').then(setMotions).catch(() => setMotions([])); }, []);
const started = rigStarted(project.rigJobId);
if (started) {
const min = Math.max(0, Math.floor((Date.now() - started) / 60000));
return <div className="box">риггинг… {min} мин<br /><small>~15 мин на 10 тыс. треугольников</small></div>;
}
return (
<FormBox title="Риг" busy={busy} error={error || (project.rigError ? 'ошибка: ' + project.rigError : '')}
hint={`SkinTokens (skin-tokens.cpp, CPU в WSL): скелет и веса скиннинга для ${project.texture?.glb ? 'текстурированной модели' : 'модели'}; ~15 мин на 10 тыс. треугольников.`}
onSubmit={() => void act(() => projectApi.rig(project.id, {
skeleton: v.skeleton === 'file' ? skeleton : null, motion: v.skeleton !== 'file' ? v.skeleton : '',
fit: v.fit, postprocess: v.postprocess,
}))}>
<label>Скелет
<select value={v.skeleton} onChange={e => set('skeleton', e.target.value)}>
<option value="">сгенерировать (экспериментально)</option>
<option value="file">свой GLB…</option>
{motions.map(m => <option key={m} value={m}>motions/{m}</option>)}
</select>
</label>
{v.skeleton === 'file' && (
<label><span>Файл скелета, GLB <small>— веса строятся под него; анимация из файла сохраняется</small></span>
<input type="file" accept=".glb,model/gltf-binary" onChange={e => setSkeleton(e.target.files?.[0] ?? null)} />
</label>
)}
{v.skeleton && (
<label>Совмещение скелета с мешем
<select value={v.fit} onChange={e => set('fit', e.target.value)}>
<option value="global">global — масштаб и центр</option>
<option value="none">none — только если скелет уже стоит в модели (не для Mixamo)</option>
<option value="articulated">articulated — подгонка рук (экспериментально)</option>
</select>
</label>
)}
<label className="check">
<input type="checkbox" checked={v.postprocess} onChange={e => set('postprocess', e.target.checked)} />
<span>Постобработка весов <small>— эвристика локальности по поверхности</small></span>
</label>
</FormBox>
);
}
/** 3D-часть проекта: форма генерации или карточки артефактов; для модели без текстур — форма Paint. */ /** 3D-часть проекта: форма генерации или карточки артефактов; для модели без текстур — форма Paint. */
function Artifacts({ project, onProject, open }: { project: ProjectT; onProject: OnProject; open: Open }) { function Artifacts({ project, onProject, open }: { project: ProjectT; onProject: OnProject; open: Open }) {
const m = project.model; const m = project.model;
@@ -270,9 +387,14 @@ function Artifacts({ project, onProject, open }: { project: ProjectT; onProject:
); );
} }
const texBy = t && TEXTURE_SOURCE_NAMES[t.source]; const texBy = t && TEXTURE_SOURCE_NAMES[t.source];
const r = project.rig;
const rigBy = r ? `SkinTokens, ${r.mode === 'rig' ? 'скелет сгенерирован' : r.motion ? 'скелет: motions/' + r.motion : 'свой скелет'}, ${
r.basedOn === 'textured' ? 'с текстурами' : 'без текстур'}, ${Math.round(r.seconds / 60)} мин` : '';
const card = (id: CardId, title: string, children: ReactNode, download: [FileRef | null | undefined, string][], updated: number) => ( const card = (id: CardId, title: string, children: ReactNode, download: [FileRef | null | undefined, string][], updated: number) => (
<ArtifactCard key={id} project={project} card={id} title={title} onProject={onProject} download={download} <ArtifactCard key={id} project={project} card={id} title={title} onProject={onProject} download={download}
date={<Origin by={id === 'model' ? GENERATOR_NAMES[m.generator] : texBy ?? ''} time={updated} />}> date={<Origin by={id === 'model' ? GENERATOR_NAMES[m.generator] : id === 'rig' ? rigBy
: isAnim(id) ? `retarget-generated: motions/${animOf(project, id)?.motion}, ${animOf(project, id)?.seconds} с`
: texBy ?? ''} time={updated} />}>
{children} {children}
</ArtifactCard> </ArtifactCard>
); );
@@ -296,7 +418,24 @@ function Artifacts({ project, onProject, open }: { project: ProjectT; onProject:
<div className={'views-grid' + (t.views.length > 4 ? ' six' : '')}> <div className={'views-grid' + (t.views.length > 4 ? ' six' : '')}>
{t.views.map(f => <Picture key={f.filename} file={f} open={open} />)} {t.views.map(f => <Picture key={f.filename} file={f} open={open} />)}
</div>, [], t.updated)} </div>, [], t.updated)}
{!t && m.generator === 'hunyuan' && !project.modelJobId && ( {project.rig && card('rig', `Риг, ${project.rig.joints} костей`,
<ModelView glb={project.rig.glb} onOpen={open} bones />, [[project.rig.glb, 'GLB'], [project.rig.skeleton, 'скелет']],
project.rig.updated)}
{project.rig?.mode === 'rig' && project.anims.map(a => card(`anim:${a.glb.filename}`, 'Анимация: ' + a.motion,
<ModelView glb={a.glb} onOpen={open} bones />, [[a.glb, 'GLB']], a.updated))}
{project.rig?.mode === 'rig' && (
<div className="pane">
<div className="pane-title">{project.anims.length ? 'Новая анимация' : 'Анимация'}</div>
<RetargetFormBlock project={project} onProject={onProject} />
</div>
)}
{!project.rig && !project.modelJobId && !project.textureJobId && (
<div className="pane">
<div className="pane-title">Риг</div>
<RigFormBlock project={project} onProject={onProject} />
</div>
)}
{!t && isUntextured(m.generator) && !project.modelJobId && (
<div className="pane"> <div className="pane">
<div className="pane-title">Текстуры</div> <div className="pane-title">Текстуры</div>
<PaintFormBlock project={project} onProject={onProject} /> <PaintFormBlock project={project} onProject={onProject} />
+3 -1
View File
@@ -74,7 +74,9 @@ model-viewer { width: 100%; height: 100%; background: var(--img-bg); --poster-co
.mv-layer { width: 100%; height: 100%; object-fit: contain; } .mv-layer { width: 100%; height: 100%; object-fit: contain; }
.mv-note { position: absolute; left: 0; right: 0; top: 50%; text-align: center; font-size: 12px; color: var(--muted); } .mv-note { position: absolute; left: 0; right: 0; top: 50%; text-align: center; font-size: 12px; color: var(--muted); }
.mv-note.err { top: auto; bottom: 36px; color: var(--error); } .mv-note.err { top: auto; bottom: 36px; color: var(--error); }
.mv-seg { position: absolute; top: 6px; left: 6px; } .mv-seg { position: absolute; top: 6px; left: 6px; right: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
.mv-anim { width: auto; padding: 2px 6px; font-size: 12px; border-radius: 7px; }
.step-form .checks { gap: 4px; max-height: 9em; overflow: auto; }
/* ---------- история ---------- */ /* ---------- история ---------- */
#status { min-height: 22px; margin: 16px 2px; color: var(--muted); } #status { min-height: 22px; margin: 16px 2px; color: var(--muted); }