skin-tokens
This commit is contained in:
Generated
+69
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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' });
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
+155
-16
@@ -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 сторон.'
|
||||||
|
: v.generator === 'hy3dwrapper'
|
||||||
|
? 'Hunyuan3D-2 mini на нодах ComfyUI-Hunyuan3DWrapper: только геометрия, очистка меша, по желанию — Instant Meshes remesh; текстуры потом — Paint.'
|
||||||
: 'Hunyuan3D-2 mini: только геометрия (~1.5–2 мин); текстуры потом — Hunyuan3D-2 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
@@ -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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user