diff --git a/package-lock.json b/package-lock.json index 0c72103..d74ba93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,14 @@ "@google/model-viewer": "^4.1.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-router": "^7.9.0" + "react-router": "^7.9.0", + "three": "^0.183.2" }, "devDependencies": { "@types/node": "^22.18.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", + "@types/three": "^0.183.1", "@vitejs/plugin-react": "^5.0.0", "typescript": "~5.9.0", "vite": "^7.1.0" @@ -302,6 +304,13 @@ "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": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1211,6 +1220,13 @@ "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": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1293,12 +1309,42 @@ "@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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "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": { "version": "5.2.0", "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" } }, + "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": { "version": "2.11.25", "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": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1630,6 +1690,13 @@ "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": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1860,8 +1927,7 @@ "version": "0.183.2", "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tinyglobby": { "version": "0.2.17", diff --git a/package.json b/package.json index f384088..d6611d7 100644 --- a/package.json +++ b/package.json @@ -12,12 +12,14 @@ "@google/model-viewer": "^4.1.0", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-router": "^7.9.0" + "react-router": "^7.9.0", + "three": "^0.183.2" }, "devDependencies": { "@types/node": "^22.18.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", + "@types/three": "^0.183.1", "@vitejs/plugin-react": "^5.0.0", "typescript": "~5.9.0", "vite": "^7.1.0" diff --git a/server/anim.ts b/server/anim.ts new file mode 100644 index 0000000..09aaf80 --- /dev/null +++ b/server/anim.ts @@ -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; +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(); + sNodes.forEach((n, i) => (n.children ?? []).forEach((c: number) => parentOf.set(c, i))); + const byName = new Map(); + 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(); + 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(); + 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(); + 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(); // `${узел}/${path}` + const times = new Set(); + 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(), rots = new Map(); + 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(); + 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; +} diff --git a/server/db.ts b/server/db.ts index b49a0a7..420b000 100644 --- a/server/db.ts +++ b/server/db.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import type { BatchImage, BatchState, FileRef, ImageBatch, Job, JobState, ModelArtifacts, Project, SettingsPage, Text2ImgInput, - TextureArtifacts, + AnimArtifacts, RigArtifacts, TextureArtifacts, } from '../shared/api.ts'; import { DB_PATH } from './config.ts'; @@ -79,12 +79,13 @@ db.exec(` ); CREATE INDEX IF NOT EXISTS Images_batchId ON Images (batchId); - -- проект: работа над одной картинкой из батча (из одной картинки — только один проект) + -- проект: работа над одной картинкой из батча (из одной картинки — сколько угодно проектов) CREATE TABLE IF NOT EXISTS Projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, 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 ( key TEXT PRIMARY KEY, @@ -125,7 +126,35 @@ addColumns('Projects', { texture: 'TEXT', // JSON TextureArtifacts: текстурированная модель, текстуры, развёртка, виды textureJobId: 'TEXT', // prompt_id идущего текстурирования 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-моделей и текстур не хранятся (результат — в проекте) — завершённые задачи удаляются db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model', 'texture') AND state <> 'pending'`); @@ -215,12 +244,12 @@ interface BatchRow { 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 = ` - SELECT i.id, i.batchId, i.filename, i.subfolder, i.type, p.id AS projectId - FROM Images i LEFT JOIN Projects p ON p.imageId = i.id`; + 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`; const SELECT_BATCH = ` 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 = ?'), }; +const ids = (s: string | null) => (s ? s.split(',').map(Number).sort((a, b) => a - b) : []); + 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 { @@ -353,13 +384,13 @@ export function deleteBatch(b: ImageBatch) { const SELECT_PROJECT = ` 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, - i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type, b.sourceType + 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, + (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`; const pq = { - insert: db.prepare('INSERT OR IGNORE INTO Projects (created, imageId) VALUES (?, ?)'), - byImage: db.prepare('SELECT id FROM Projects WHERE imageId = ?'), + insert: db.prepare('INSERT INTO Projects (created, imageId) VALUES (?, ?)'), get: db.prepare(SELECT_PROJECT + ' WHERE p.id = ?'), list: db.prepare(SELECT_PROJECT + ' ORDER BY p.created DESC, p.id DESC LIMIT ?'), 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), 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`), byTextureJob: db.prepare('SELECT id FROM Projects WHERE textureJobId = ?'), 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 = ?'), - clearModel: db.prepare('UPDATE Projects SET model = NULL, modelError = NULL, texture = NULL, textureError = NULL WHERE id = ?'), - clearTexture: db.prepare('UPDATE Projects SET texture = NULL, textureError = NULL WHERE id = ?'), + finishTexture: db.prepare(`UPDATE Projects SET texture = COALESCE(?1, texture), rig = CASE WHEN ?1 IS NULL THEN rig ELSE NULL END, + anim = CASE WHEN ?1 IS NULL THEN anim ELSE NULL END, + 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-модели проекта поставлена в очередь. */ @@ -420,6 +464,40 @@ export function clearProjectTexture(id: number) { 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 остаётся). */ export function clearProjectClean(id: number) { 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; model: string | null; modelJobId: string | null; modelError: 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; + projectIds: string | 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 { return { 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, cleanJobId: r.cleanJobId, ...(r.cleanError ? { cleanError: r.cleanError } : {}), model: json(r.model), modelJobId: r.modelJobId, ...(r.modelError ? { modelError: r.modelError } : {}), 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; } -/** Проект из картинки: из одной картинки — только один проект; если уже есть, возвращается он. */ -export function projectForImage(imageId: number): number { - pq.insert.run(Date.now(), imageId); - return (pq.byImage.get(imageId) as { id: number }).id; +/** Новый проект из картинки (из одной картинки — сколько угодно проектов). */ +export function createProjectFor(imageId: number): number { + return Number(pq.insert.run(Date.now(), imageId).lastInsertRowid); } export function getProject(id: number): Project | null { diff --git a/server/glb.ts b/server/glb.ts index 48cdfa5..cbaca59 100644 --- a/server/glb.ts +++ b/server/glb.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type { FileRef } from '../shared/api.ts'; +import { retargetAnimations } from './anim.ts'; import { COMFY_OUTPUT } from './config.ts'; interface Gltf { @@ -67,3 +68,265 @@ export function glbTriangles(ref: FileRef): number | null { if (fd !== undefined) fs.closeSync(fd); } } + +// ---------- перенос скелета и весов на исходный GLB ---------- + +/* eslint-disable @typescript-eslint/no-explicit-any */ +type Json = Record; + +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(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(); + 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(); + 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 }; +} diff --git a/server/index.ts b/server/index.ts index 9e7645c..62fe78d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -10,19 +10,21 @@ import type { CleanupJob, CleanupRequest, FileRef, ImageBatch, ImportRequest, ModelJob, Project, ProjectModelRequest, ProjectTextureRequest, QueueInfo, SettingsPage, Text2ImgInput, Text2ImgJob, TextureJob, } from '../shared/api.ts'; -import { DEFAULT_TEMPLATE, isLocked } from '../shared/api.ts'; -import { COMFY, DB_PATH, DIST, HOST, PORT } from './config.ts'; +import { DEFAULT_TEMPLATE, isLocked, isUntextured } from '../shared/api.ts'; +import { COMFY, COMFY_OUTPUT, DB_PATH, DIST, HOST, PORT } from './config.ts'; import { ComfyError, comfyJson, queuePrompt, uploadImage, view } from './comfy.ts'; import { - clearProjectClean, clearProjectModel, clearProjectTexture, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage, - getProject, getSettings, insertJob, listBatches, listProjects, projectForImage, putSettings, startBatch, startProjectClean, + addProjectAnim, clearProjectClean, clearProjectModel, clearProjectRig, clearProjectTexture, finishProjectRig, removeProjectAnim, + startProjectRig, createTextBatch, createUploadBatch, deleteBatch, getBatch, getImage, + getProject, getSettings, insertJob, listBatches, listProjects, createProjectFor, putSettings, startBatch, startProjectClean, startProjectModel, startProjectTexture, transaction, updateTextInput, } 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 { liveStatus, startTracker } from './tracker.ts'; import { - cleanupGraph, escapeTemplate, kindOf, modelGraph, paintGraph, renderTemplate, text2imgGraph, trellisGraph, + cleanupGraph, escapeTemplate, hy3dWrapperGraph, kindOf, modelGraph, paintGraph, renderTemplate, text2imgGraph, trellisGraph, } from './workflows.ts'; class BadRequest extends Error {} @@ -35,7 +37,7 @@ const UPLOAD_DIR = 'img2glb_upload'; type Req = http.IncomingMessage; 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) { 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) { const imageId = Number((await readJson<{ imageId: number }>(req)).imageId); 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 { @@ -226,9 +228,10 @@ async function projectModel(req: Req, res: Res, projectId: number) { if (!p.clean) throw new Conflict('сначала почистите исходник'); if (p.modelJobId) throw new Conflict('модель уже генерируется'); if (p.textureJobId) throw new Conflict('идёт текстурирование'); + if (p.rigJobId) throw new Conflict('идёт риггинг'); const b = await readJson(req); 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 params: ModelJob['params']; if (generator === 'trellis') { @@ -238,6 +241,16 @@ async function projectModel(req: Req, res: Res, projectId: number) { const pbr = b.pbr === true; id = await queuePrompt(trellisGraph({ image: src.image, prefix: 'img2glb_trellis/' + src.stem, 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 { const seed = parseSeed(b.seed); 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) { const p = projectOrThrow(id); - if (p.modelJobId || p.textureJobId) throw new Conflict('идёт генерация'); + if (p.modelJobId || p.textureJobId || p.rigJobId) throw new Conflict('идёт генерация'); clearProjectModel(id); send(res, 200, withLiveProject(projectOrThrow(id))); } @@ -272,8 +285,8 @@ async function projectTexture(req: Req, res: Res, projectId: number) { const p = projectOrThrow(projectId); if (!p.clean) throw new Conflict('сначала почистите исходник'); if (!p.model) throw new Conflict('сначала сделайте 3D-модель'); - if (p.model.generator !== 'hunyuan') throw new Conflict('модель уже с текстурами'); - if (p.modelJobId || p.textureJobId) throw new Conflict('идёт генерация'); + if (!isUntextured(p.model.generator)) throw new Conflict('модель уже с текстурами'); + if (p.modelJobId || p.textureJobId || p.rigJobId) throw new Conflict('идёт генерация'); const b = await readJson(req); const src = sourceImage(p.clean); 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) { 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); else clearProjectTexture(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, кадрирование по маске в квадрат с отступом, заливка фона. * Результат заменяет прежнюю очищенную картинку проекта; история очисток не хранится. @@ -443,7 +597,7 @@ http.createServer(async (req, res) => { } if (route === 'GET /api/projects') return send(res, 200, listProjects().map(withLiveProject)); 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 === '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])); @@ -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 === '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 === '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 === '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 === '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/glb-flat') return glbFlat(res, u.searchParams); if (u.pathname.startsWith('/api/')) return send(res, 404, { error: 'not found' }); diff --git a/server/rig.ts b/server/rig.ts new file mode 100644 index 0000000..a907f6f --- /dev/null +++ b/server/rig.ts @@ -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 { + return new Promise((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 { + 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 { + 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'); +} diff --git a/server/workflows.ts b/server/workflows.ts index c9ed627..4442a23 100644 --- a/server/workflows.ts +++ b/server/workflows.ts @@ -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, градусы поворота вокруг вертикали. */ 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 { const types = Object.values((graph ?? {}) as Graph).map(n => n?.class_type); if (types.includes('Img2GlbHunyuanPaint')) return 'текстура'; - if (types.includes('SaveGLB')) return '3D-модель'; + if (types.includes('SaveGLB') || types.includes('Hy3DExportMesh')) return '3D-модель'; if (types.includes('RemoveBackground')) return 'очистка фона'; if (types.includes('KSampler') && types.includes('CheckpointLoaderSimple')) return 'генерация'; return 'другое'; diff --git a/shared/api.ts b/shared/api.ts index 2258e3d..af84bdc 100644 --- a/shared/api.ts +++ b/shared/api.ts @@ -31,10 +31,10 @@ export interface Text2ImgInput { */ export type BatchState = 'draft' | 'pending' | 'done' | 'error'; -/** Картинка батча (Images): id и проект, если из неё создан проект. */ +/** Картинка батча (Images): id и проекты, созданные из неё (из одной картинки — сколько угодно). */ export interface BatchImage extends FileRef { id: number; - projectId: number | null; + projectIds: number[]; } /** Проект — работа над одной картинкой из батча. */ @@ -72,12 +72,57 @@ export interface Project { textureJobId: string | null; textureError?: string; 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-модель. */ -export type ModelGenerator = 'hunyuan' | 'trellis'; +/** Анимация из app/data/motions на скелете рига: модель со скелетом рига и движением. */ +export interface AnimArtifacts { + glb: FileRef; + /** файлы движений через запятую (пути в app/data/motions) — подпись */ + motion: string; + /** файлы движений по порядку анимаций в GLB (у старых записей нет — тогда одна: motion) */ + motions?: string[]; + /** длительность ретаргета, с */ + seconds: number; + updated: number; +} -export const GENERATOR_NAMES: Record = { 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 = { + 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 { @@ -123,6 +168,9 @@ export interface ProjectModelRequest { octree: number | string; /** целевое число треугольников после упрощения */ faces: number | string; + /** Hy3D wrapper: перестроить сетку Instant Meshes (Hy3DIMRemesh) до remeshVertices вершин */ + remesh: boolean; + remeshVertices: number | string; /** TRELLIS.2: размер текстуры, px */ textureSize: number | string; /** TRELLIS.2: металличность и шероховатость (карты metallic, roughness) в модели */ @@ -175,7 +223,7 @@ export type ImageBatch = TextBatch | UploadBatch; /** В батче есть картинка, из которой создан проект. */ 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 { @@ -230,6 +278,8 @@ export interface ModelParams { /** Hunyuan3D-2 mini */ steps?: number; octree?: number; + /** Hy3D wrapper: Hy3DIMRemesh, 0 — выключен */ + remeshVertices?: number; /** TRELLIS.2 */ textureSize?: number; pbr?: boolean; @@ -292,6 +342,9 @@ export interface ModelSettings { steps: string; octree: string; faces: string; + /** Hy3D wrapper */ + remesh: boolean; + remeshVertices: string; /** TRELLIS.2 */ trellisFaces: string; textureSize: string; @@ -304,8 +357,22 @@ export interface TextureSettings { 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 = { - 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; diff --git a/src/components/BatchCard.tsx b/src/components/BatchCard.tsx index e9fd9cd..6027bcf 100644 --- a/src/components/BatchCard.tsx +++ b/src/components/BatchCard.tsx @@ -49,7 +49,7 @@ export function BatchCard({ batch: b }: { batch: ImageBatch }) {
{b.images.map(i => ( -
+
{i.filename}
))} diff --git a/src/components/ModelView.tsx b/src/components/ModelView.tsx index 5532e39..72ed7ba 100644 --- a/src/components/ModelView.tsx +++ b/src/components/ModelView.tsx @@ -8,7 +8,8 @@ declare module 'react' { interface IntrinsicElements { 'model-viewer': DetailedHTMLProps, HTMLElement> & { 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({ options, value, onChange }: { } /** - * Готовая модель: интерактивный GLB с переключателем «сглаживание / без». + * Готовая модель: интерактивный GLB с переключателем «сглаживание / без»; для рига (bones) — ещё «скелет» (включён): + * кости линиями и суставы точками поверх модели (модуль skeleton.ts грузится при первом включении). * Пока GLB грузится (или если не загрузился) — вместо 3D показывается превью; что показывать вообще * (модель или форму) решает вызывающий по записи в базе, а не по загрузке model-viewer. */ -export function ModelView({ glb, preview, onOpen }: { - glb: FileRef; preview?: FileRef | null; onOpen: (src: string, caption: string) => void; +export function ModelView({ glb, preview, onOpen, bones }: { + glb: FileRef; preview?: FileRef | null; onOpen: (src: string, caption: string) => void; bones?: boolean; }) { const [ready, setReady] = useState(false); // скрипт model-viewer загружен const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading'); // загрузка GLB const [smooth, setSmooth] = useState(true); // «без сглаживания» — тот же GLB без нормалей (плоские грани) + const [skeleton, setSkeleton] = useState(!!bones); + // анимации в GLB (для переключателя, если их больше одной) и выбранная + const [anims, setAnims] = useState([]); + const [anim, setAnim] = useState(''); const ref = useRef(null); const src = smooth ? fileUrl(glb) : '/api/glb-flat?' + new URLSearchParams({ filename: glb.filename, subfolder: glb.subfolder }); @@ -59,13 +65,28 @@ export function ModelView({ glb, preview, onOpen }: { useEffect(() => { const el = ref.current; 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'); el.addEventListener('load', onLoad); el.addEventListener('error', onError); return () => { el.removeEventListener('load', onLoad); el.removeEventListener('error', onError); }; }, [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 && ( {preview.filename} onOpen(fileUrl(preview), preview.filename)} /> ); @@ -73,7 +94,8 @@ export function ModelView({ glb, preview, onOpen }: { return (
{ready && ( - )} @@ -82,6 +104,14 @@ export function ModelView({ glb, preview, onOpen }: { setSmooth(v === 'smooth')} /> + {bones && ( + setSkeleton(x => !x)} /> + )} + {anims.length > 1 && ( + + )}
); diff --git a/src/components/skeleton.ts b/src/components/skeleton.ts new file mode 100644 index 0000000..d0dc908 --- /dev/null +++ b/src/components/skeleton.ts @@ -0,0 +1,50 @@ +// Скелет поверх модели прямо в : кости — линии (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)[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?.(); + }; +} diff --git a/src/lib/batches.ts b/src/lib/batches.ts index daab099..8e4b45b 100644 --- a/src/lib/batches.ts +++ b/src/lib/batches.ts @@ -37,7 +37,21 @@ export const projectApi = { texture: (id: number, params: ProjectTextureRequest) => api(`/api/projects/${id}/texture`, { body: params }), /** Удалить текстурную группу (у TRELLIS.2 — вместе с моделью). */ removeTexture: (id: number) => api(`/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(await fetch(`/api/projects/${id}/rig`, { method: 'POST', body: form })); + }, + removeRig: (id: number) => api(`/api/projects/${id}/rig`, { method: 'DELETE' }), + /** Анимация из motions на сгенерированный скелет рига (секунды). */ + retarget: (id: number, motions: string[]) => api(`/api/projects/${id}/retarget`, { body: { motions } }), + removeAnim: (id: number, file: string) => + api(`/api/projects/${id}/retarget?` + new URLSearchParams({ file }), { method: 'DELETE' }), + /** Новый проект из картинки (из одной картинки можно сделать несколько). */ forImage: (imageId: number) => api('/api/projects', { body: { imageId } }), get: (id: number) => api('/api/projects/' + id), }; @@ -88,7 +102,7 @@ export function useProject(id: number | null) { 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(() => { if (!working) return; const t = setInterval(() => { void refresh(); }, POLL_MS); diff --git a/src/pages/Batch.tsx b/src/pages/Batch.tsx index 0c90f67..d9523c2 100644 --- a/src/pages/Batch.tsx +++ b/src/pages/Batch.tsx @@ -9,8 +9,8 @@ import { api, fileUrl } from '../lib/api.ts'; import { batchApi, projectApi, useBatch } from '../lib/batches.ts'; /** - * Картинки батча: клик — на весь экран; «в проект» — создать проект из картинки (или перейти в уже созданный); - * пока идёт генерация — заглушки. + * Картинки батча: клик — на весь экран; «+ проект» — новый проект из картинки (их может быть несколько), + * «#N» — уже созданные проекты; пока идёт генерация — заглушки. */ function Images({ batch }: { batch: ImageBatch }) { const navigate = useNavigate(); @@ -37,9 +37,11 @@ function Images({ batch }: { batch: ImageBatch }) { diff --git a/src/pages/Project.tsx b/src/pages/Project.tsx index 2f1f7bc..8bd5436 100644 --- a/src/pages/Project.tsx +++ b/src/pages/Project.tsx @@ -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 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'; -import { GENERATOR_NAMES } from '../../shared/api.ts'; +import { GENERATOR_NAMES, isUntextured, rigStarted } from '../../shared/api.ts'; import { ModelView } from '../components/ModelView.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 { useSettings } from '../lib/settings.ts'; const CLEAN_DEFAULTS: CleanupSettings = { size: '1024', margin: '154', grow: '0', background: '#ffffff' }; 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, }; 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 OnProject = (p: ProjectT) => void; @@ -115,10 +119,13 @@ function ModelFormBlock({ project, onProject }: { project: ProjectT; onProject: void act(() => projectApi.model(project.id, { generator: v.generator, seed: v.seed, steps: v.steps, octree: v.octree, faces: trellis ? v.trellisFaces : v.faces, textureSize: v.textureSize, pbr: v.trellisPbr, + remesh: v.generator === 'hy3dwrapper' && v.remesh, remeshVertices: v.remeshVertices, }))}> - @@ -190,13 +211,18 @@ function PaintFormBlock({ project, onProject }: { project: ProjectT; onProject: } /** Карточки 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 = { +const CARD_NAMES: Record, string> = { 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[] { 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); 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; } @@ -215,6 +243,8 @@ function presentCards(p: ProjectT): CardId[] { */ function removedWith(p: ProjectT, card: CardId): CardId[] { 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; 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 removed = removedWith(project, card); const remove = () => { - const others = removed.filter(c => c !== card).map(c => CARD_NAMES[c]); - const msg = `Удалить «${CARD_NAMES[card]}» из проекта?` + (others.length ? `\nВместе с ней будут удалены: ${others.join(', ')}.` : '') + const others = removed.filter(c => c !== card).map(c => cardName(project, c)); + const msg = `Удалить «${cardName(project, card)}» из проекта?` + (others.length ? `\nВместе с ней будут удалены: ${others.join(', ')}.` : '') + '\nФайлы в ComfyUI/output останутся.'; 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 (
{title}
@@ -257,6 +288,92 @@ function Picture({ file, open }: { file: FileRef; open: Open }) { return {file.filename} 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([]); + useEffect(() => { api('/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 ( + +
+ {motions.map(m => ( + + ))} +
+ {busy && ретаргет {chosen.length > 1 ? `${chosen.length} движений` : ''}…} +
+ ); +} + +/** + * Форма рига 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(null); + const [motions, setMotions] = useState([]); + useEffect(() => { api('/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
риггинг… {min} мин
~15 мин на 10 тыс. треугольников
; + } + return ( + void act(() => projectApi.rig(project.id, { + skeleton: v.skeleton === 'file' ? skeleton : null, motion: v.skeleton !== 'file' ? v.skeleton : '', + fit: v.fit, postprocess: v.postprocess, + }))}> + + {v.skeleton === 'file' && ( + + )} + {v.skeleton && ( + + )} + + + ); +} + /** 3D-часть проекта: форма генерации или карточки артефактов; для модели без текстур — форма Paint. */ function Artifacts({ project, onProject, open }: { project: ProjectT; onProject: OnProject; open: Open }) { 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 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) => ( }> + date={}> {children} ); @@ -296,7 +418,24 @@ function Artifacts({ project, onProject, open }: { project: ProjectT; onProject:
4 ? ' six' : '')}> {t.views.map(f => )}
, [], t.updated)} - {!t && m.generator === 'hunyuan' && !project.modelJobId && ( + {project.rig && card('rig', `Риг, ${project.rig.joints} костей`, + , [[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, + , [[a.glb, 'GLB']], a.updated))} + {project.rig?.mode === 'rig' && ( +
+
{project.anims.length ? 'Новая анимация' : 'Анимация'}
+ +
+ )} + {!project.rig && !project.modelJobId && !project.textureJobId && ( +
+
Риг
+ +
+ )} + {!t && isUntextured(m.generator) && !project.modelJobId && (
Текстуры
diff --git a/src/styles.css b/src/styles.css index 5fd4901..826e59b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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-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-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); }