skin-tokens

This commit is contained in:
Aggtaa
2026-09-30 07:38:21 +03:00
parent 8db94e5c31
commit 9cad7452bb
16 changed files with 1314 additions and 78 deletions
+173 -14
View File
@@ -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<ProjectModelRequest>(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<ProjectTextureRequest>(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' });