feat: 3D 化身服务端预渲染(Node 渲染器 + 帧轮播 + 降级)

- Node 渲染器(three r162 + headless-gl + pngjs):gen-templates 程序化生成简化人体 GLB(6 脸 × 6 体 × 5 肤色),render.js 绕 Y 轴 36 帧 256x512 白底渲染
- agent/render.go:RenderAvatarFrames 帧目录缓存复用 + GLB 缺失自动生成 + 15 分钟超时;render.enabled/node_bin 配置,node_bin 路径缺失回退 PATH
- Build 异步化:写库 processing → goroutine 渲染 → done+frames_url / failed+error(客户端降级静态占位);修 BodyMeasurement 无记录时 Build 报错
- avatar_model 容错 ALTER 加 frames_url 列;AvatarGetRes 透传 frames_url
- TDD:renderJob 状态机测试(成功 done / 失败 failed 降级)
- 客户端 avatar_viewer 帧轮播(120ms × 36 帧,errorBuilder 降级占位),avatar_provider 解析 frames_url
- Dockerfile 双阶段:node:20-alpine 编译 gl 原生模块 + 运行时带 nodejs/libglvnd
This commit is contained in:
2026-07-31 15:37:02 +08:00
parent 775995f3e8
commit fd13efb9c0
14 changed files with 1249 additions and 33 deletions
+3
View File
@@ -11,3 +11,6 @@ workspace/
# 系统
.DS_Store
# 渲染器依赖
scripts/avatar-render/node_modules/
+11 -1
View File
@@ -11,14 +11,24 @@ COPY . .
RUN go mod download && go mod tidy
RUN go build -ldflags="-s -w" -o main ./main.go
# 3D 化身渲染器(Node + headless-gl,需原生编译)
FROM node:20-alpine AS renderer
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache git python3 make g++ mesa mesa-gles mesa-egl libglvnd-dev ca-certificates tzdata
WORKDIR /render
COPY scripts/avatar-render/package*.json ./
RUN npm ci --omit=dev --registry=https://registry.npmmirror.com
FROM alpine:3.19
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk add --no-cache ca-certificates tzdata
&& apk add --no-cache ca-certificates tzdata libstdc++ libgcc libglvnd nodejs
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /app
COPY --from=builder /build/config.yml .
COPY --from=builder /build/main .
COPY --from=renderer /render/node_modules ./scripts/avatar-render/node_modules
COPY scripts/avatar-render/ ./scripts/avatar-render/
RUN mkdir -p /app/workspace
EXPOSE 3007
ENTRYPOINT ["./main"]
+5
View File
@@ -51,3 +51,8 @@ payment:
ad:
limit_effect_extra: 2
limit_vip_trial: 1
# 3D 化身预渲染(Node + headless-glnode_bin 需指向 gl 有预编译二进制的 Node 版本)
render:
enabled: true
node_bin: "/Users/zhangbin/.nvm/versions/node/v18.20.4/bin/node"
+138
View File
@@ -0,0 +1,138 @@
// 程序化生成简化人体化身 GLB(脸型/体型/肤色三参数组合)。
// 用法: node gen-templates.js --out <dir> [--faces 6] [--bodies 6] [--skins 5]
// 索引对齐 MatchTemplatesface 0 起、body/skin 1 起;已存在的 GLB 跳过(幂等)。
import { argv } from 'node:process';
import fs from 'node:fs';
import path from 'node:path';
import * as THREE from 'three';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
// Node 无 FileReaderGLTFExporter 二进制导出需要它(onloadend 回调风格)
if (typeof globalThis.FileReader === 'undefined') {
globalThis.FileReader = class {
constructor() { this.result = null; }
set onloadend(fn) { this._onloadend = fn; }
get onloadend() { return this._onloadend; }
readAsArrayBuffer(blob) {
blob.arrayBuffer().then((buf) => {
this.result = buf;
this._onloadend && this._onloadend();
});
}
};
}
function parseArgs() {
const a = { faces: 6, bodies: 6, skins: 5 };
for (let i = 2; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const val = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[++i] : true;
a[key] = val;
}
}
a.out = a.out || '.';
return a;
}
// 5 档肤色(PBR base color
const SKIN_TONES = ['#FDE4CF', '#F3C39B', '#D99B6C', '#A96B3F', '#6B4226'];
// 脸型变体:发型形状/颜色差异
const FACE_HAIR = [
{ style: 'short', color: '#2B2B2B' }, // 短发
{ style: 'fringe', color: '#4A3728' }, // 刘海长发
{ style: 'bob', color: '#8C5A2B' }, // 波波头
];
// 体型变体:宽度/高度比例
const BODY_VARIANTS = [
{ name: 'slim', width: 0.85, height: 1.0 },
{ name: 'normal', width: 1.0, height: 1.0 },
{ name: 'broad', width: 1.18, height: 0.96 },
];
function buildAvatar(faceIdx, bodyIdx, skinIdx) {
const face = FACE_HAIR[faceIdx % FACE_HAIR.length];
const body = BODY_VARIANTS[bodyIdx % BODY_VARIANTS.length];
const skin = SKIN_TONES[skinIdx % SKIN_TONES.length];
const root = new THREE.Group();
const skinMat = new THREE.MeshStandardMaterial({ color: skin, roughness: 0.7 });
const hairMat = new THREE.MeshStandardMaterial({ color: face.color, roughness: 0.85 });
const clothMat = new THREE.MeshStandardMaterial({ color: '#3D5A80', roughness: 0.8 });
const W = body.width;
const H = body.height;
// 躯干:胶囊体
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.22 * W, 0.5, 8, 16), clothMat);
torso.position.y = 1.12 * H;
root.add(torso);
// 头:球体
const head = new THREE.Mesh(new THREE.SphereGeometry(0.16 * W, 24, 18), skinMat);
head.position.y = 1.68 * H;
root.add(head);
// 发型:覆盖头顶的半球壳(按脸型变体)
const hair = new THREE.Mesh(new THREE.SphereGeometry(0.175 * W, 24, 12, 0, Math.PI * 2, 0, Math.PI * 0.52), hairMat);
hair.position.y = 1.68 * H + 0.03;
root.add(hair);
if (face.style === 'fringe') {
const fringe = new THREE.Mesh(new THREE.BoxGeometry(0.16 * W, 0.05, 0.2 * W), hairMat);
fringe.position.y = 1.7 * H;
fringe.position.z = -0.12 * W;
fringe.rotation.x = -0.25;
root.add(fringe);
} else if (face.style === 'bob') {
const back = new THREE.Mesh(new THREE.BoxGeometry(0.3 * W, 0.3, 0.06), hairMat);
back.position.y = 1.52 * H;
back.position.z = 0.14 * W;
root.add(back);
}
// 上肢
for (const side of [-1, 1]) {
const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.075 * W, 0.42, 6, 10), skinMat);
arm.position.set(side * 0.32 * W, 1.32 * H, 0);
arm.rotation.z = side * 0.06;
root.add(arm);
}
// 下肢
for (const side of [-1, 1]) {
const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.1 * W, 0.62, 6, 10), clothMat);
leg.position.set(side * 0.12 * W, 0.55 * H, 0);
root.add(leg);
}
return root;
}
const args = parseArgs();
fs.mkdirSync(args.out, { recursive: true });
const exporter = new GLTFExporter();
let generated = 0;
for (let f = 0; f < args.faces; f++) {
for (let b = 1; b <= args.bodies; b++) {
for (let s = 1; s <= args.skins; s++) {
const name = `avatar_f${f}_b${b}_s${s}.glb`;
const outPath = path.join(args.out, name);
if (fs.existsSync(outPath)) continue;
const scene = buildAvatar(f, b, s);
const buf = await new Promise((resolve, reject) => {
exporter.parse(
scene,
(result) => resolve(Buffer.from(result)),
(err) => reject(err),
{ binary: true }
);
});
fs.writeFileSync(outPath, buf);
generated++;
}
}
}
console.log(`generated ${generated} GLB (skipped existing) -> ${args.out}`);
+713
View File
@@ -0,0 +1,713 @@
{
"name": "avatar-render",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "avatar-render",
"version": "1.0.0",
"dependencies": {
"gl": "^9.0.0-rc.10",
"pngjs": "^7.0.0",
"three": "0.162.0"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/abbrev": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz",
"integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bit-twiddle": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz",
"integrity": "sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA=="
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"engines": {
"node": ">=18"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"engines": {
"node": ">=6"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"engines": {
"node": ">=6"
}
},
"node_modules/exponential-backoff": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
"integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
},
"node_modules/gl": {
"version": "9.0.0-rc.10",
"resolved": "https://registry.npmjs.org/gl/-/gl-9.0.0-rc.10.tgz",
"integrity": "sha512-G6lYaWoan0d2d8UO0UmaSS8zqyyZwYt6q2dFVJ2hD62sRWUwkMIH+Jp7gEoQesLlo28Nzelh+GIYz1qOUa5WmQ==",
"hasInstallScript": true,
"dependencies": {
"bindings": "^1.5.0",
"bit-twiddle": "^1.0.2",
"glsl-tokenizer": "^2.1.5",
"nan": "^2.26.2",
"node-gyp": "^12.2.0",
"prebuild-install": "^7.1.3"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/glsl-tokenizer": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/glsl-tokenizer/-/glsl-tokenizer-2.1.5.tgz",
"integrity": "sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==",
"dependencies": {
"through2": "^0.6.3"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
},
"node_modules/isarray": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="
},
"node_modules/isexe": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
"integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==",
"engines": {
"node": ">=20"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
},
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ=="
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="
},
"node_modules/node-abi": {
"version": "3.94.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
"integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-gyp": {
"version": "12.4.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
"integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==",
"dependencies": {
"env-paths": "^2.2.0",
"exponential-backoff": "^3.1.1",
"graceful-fs": "^4.2.6",
"nopt": "^9.0.0",
"proc-log": "^6.0.0",
"semver": "^7.3.5",
"tar": "^7.5.4",
"tinyglobby": "^0.2.12",
"undici": "^6.25.0",
"which": "^6.0.0"
},
"bin": {
"node-gyp": "bin/node-gyp.js"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/nopt": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz",
"integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==",
"dependencies": {
"abbrev": "^4.0.0"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/picomatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/proc-log": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
"integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar": {
"version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-fs/node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/three": {
"version": "0.162.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.162.0.tgz",
"integrity": "sha512-xfCYj4RnlozReCmUd+XQzj6/5OjDNHBy5nT6rVwrOKGENAvpXe2z1jL+DZYaMu4/9pNsjH/4Os/VvS9IrH7IOQ=="
},
"node_modules/through2": {
"version": "0.6.5",
"resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz",
"integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==",
"dependencies": {
"readable-stream": ">=1.0.33-1 <1.1.0-0",
"xtend": ">=4.0.0 <4.1.0-0"
}
},
"node_modules/through2/node_modules/readable-stream": {
"version": "1.0.34",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz",
"integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.1",
"isarray": "0.0.1",
"string_decoder": "~0.10.x"
}
},
"node_modules/through2/node_modules/string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/undici": {
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"engines": {
"node": ">=18.17"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/which": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz",
"integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==",
"dependencies": {
"isexe": "^4.0.0"
},
"bin": {
"node-which": "bin/which.js"
},
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"engines": {
"node": ">=18"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "avatar-render",
"version": "1.0.0",
"private": true,
"description": "服务端 3D 化身预渲染:GLB -> 36 帧旋转 PNGthree.js + headless-gl + pngjs",
"type": "module",
"dependencies": {
"gl": "^9.0.0-rc.10",
"pngjs": "^7.0.0",
"three": "0.162.0"
}
}
+115
View File
@@ -0,0 +1,115 @@
// 化身 GLB -> 36 帧旋转 PNG(绕 Y 轴 10° 步进),服务端预渲染。
// 用法: node render.js --glb <path> --out <dir> [--frames 36] [--size 256x512]
import { argv } from 'node:process';
import fs from 'node:fs';
import path from 'node:path';
import createGL from 'gl';
import { PNG } from 'pngjs';
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
function parseArgs() {
const a = {};
for (let i = 2; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const val = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[++i] : true;
a[key] = val;
}
}
if (!a.glb || !a.out) {
console.error('用法: node render.js --glb <path> --out <dir> [--frames 36] [--size 256x512]');
process.exit(1);
}
a.frames = a.frames === true ? 36 : parseInt(a.frames, 10) || 36;
const [w, h] = (a.size === true ? '256x512' : String(a.size)).split('x').map(Number);
a.width = w || 256;
a.height = h || 512;
return a;
}
const args = parseArgs();
const { width, height, frames } = args;
const gl = createGL(width, height, { preserveDrawingBuffer: true });
if (!gl) {
console.error('headless-gl 初始化失败(容器内需 mesa/libglvnd');
process.exit(2);
}
const canvas = {
width,
height,
style: {},
addEventListener: () => {},
removeEventListener: () => {},
clientWidth: width,
clientHeight: height,
getContext: () => gl,
};
const renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: false });
renderer.setClearColor(0xffffff, 1);
renderer.setSize(width, height, false);
const scene = new THREE.Scene();
scene.add(new THREE.AmbientLight(0xffffff, 1.1));
const dirLight = new THREE.DirectionalLight(0xffffff, 1.4);
dirLight.position.set(3, 6, 4);
scene.add(dirLight);
scene.add(new THREE.DirectionalLight(0xffffff, 0.5).translateY(-4).translateX(-3));
const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 100);
const loader = new GLTFLoader();
const loadGlb = () =>
new Promise((resolve, reject) => {
// Buffer 需转成 ArrayBuffer 才能触发 GLB 头解析
const bin = fs.readFileSync(args.glb);
const ab = bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.byteLength);
loader.parse(ab, '', (gltf) => resolve(gltf.scene), (err) => reject(err));
});
loadGlb()
.then((object) => {
scene.add(object);
render(object);
})
.catch((err) => {
console.error('GLB 解析失败:', err && err.message ? err.message : err);
process.exit(3);
});
function render(object) {
// 包围盒 -> 相机半径与观测高度
const box = new THREE.Box3().setFromObject(object);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const radius = Math.max(size.x, size.z) * 1.6 + 0.6;
const lookY = center.y + size.y * 0.35;
const cameraY = center.y + size.y * 0.35;
fs.mkdirSync(args.out, { recursive: true });
const pixels = new Uint8Array(width * height * 4);
const rowSize = width * 4;
const png = new PNG({ width, height });
for (let i = 0; i < frames; i++) {
const angle = (i / frames) * Math.PI * 2;
camera.position.set(Math.sin(angle) * radius, cameraY, Math.cos(angle) * radius);
camera.lookAt(0, lookY, 0);
renderer.render(scene, camera);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
const buf = Buffer.from(pixels.buffer);
for (let y = 0; y < height; y++) {
buf.copy(png.data, y * rowSize, (height - 1 - y) * rowSize, (height - y) * rowSize);
}
const out = path.join(args.out, `frame_${String(i).padStart(3, '0')}.png`);
fs.writeFileSync(out, PNG.sync.write(png));
}
console.log(`rendered ${frames} frames -> ${args.out}`);
process.exit(0);
}
+99
View File
@@ -0,0 +1,99 @@
package agent
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gogf/gf/v2/frame/g"
)
const (
renderFramesCount = 36
renderFrameSize = "256x512"
renderTimeout = 15 * time.Minute
)
// RenderAvatarFrames 将化身 GLB 预渲染为绕 Y 轴旋转帧序列,返回帧目录访问 URL。
// 组合键 {face}_{body}_{skin};帧目录已就绪直接复用,GLB 缺失先程序化生成模板。
// render.enabled=false 或渲染失败时返回 error,由调用方降级。
func RenderAvatarFrames(ctx context.Context, faceId, bodyId, skinIdx int) (framesURL string, err error) {
if !g.Cfg().MustGet(ctx, "render.enabled", true).Bool() {
return "", errors.New("3D 渲染服务未启用")
}
key := fmt.Sprintf("f%d_b%d_s%d", faceId, bodyId, skinIdx)
dir := filepath.Join("workspace", "avatar_frames", key)
if framesReady(dir) {
return "/workspace/avatar_frames/" + key, nil
}
glb := filepath.Join("workspace", "templates", fmt.Sprintf("avatar_f%d_b%d_s%d.glb", faceId, bodyId, skinIdx))
if _, err := os.Stat(glb); err != nil {
if err := runTemplates(ctx); err != nil {
return "", fmt.Errorf("生成化身模板失败: %w", err)
}
}
if err := runRender(ctx, glb, dir); err != nil {
return "", err
}
return "/workspace/avatar_frames/" + key, nil
}
func framesReady(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
count := 0
for _, e := range entries {
if strings.HasPrefix(e.Name(), "frame_") && strings.HasSuffix(e.Name(), ".png") {
count++
}
}
return count >= renderFramesCount
}
func nodeBin(ctx context.Context) string {
bin := g.Cfg().MustGet(ctx, "render.node_bin", "node").String()
if bin == "" {
return "node"
}
// 配置路径不存在(如容器环境)→ 回退 PATH 中的 node
if _, err := os.Stat(bin); err != nil {
return "node"
}
return bin
}
func runTemplates(ctx context.Context) error {
if err := os.MkdirAll(filepath.Join("workspace", "templates"), 0o755); err != nil {
return err
}
return execNode(ctx, filepath.Join("scripts", "avatar-render", "gen-templates.js"),
"--out", filepath.Join("workspace", "templates"))
}
func runRender(ctx context.Context, glb, out string) error {
if err := os.MkdirAll(out, 0o755); err != nil {
return err
}
return execNode(ctx, filepath.Join("scripts", "avatar-render", "render.js"),
"--glb", glb, "--out", out,
"--frames", fmt.Sprint(renderFramesCount), "--size", renderFrameSize)
}
func execNode(ctx context.Context, script string, args ...string) error {
cmdCtx, cancel := context.WithTimeout(ctx, renderTimeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, nodeBin(ctx), append([]string{script}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("node 渲染失败: %v: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
@@ -32,6 +32,7 @@ func (c *avatar) Get(ctx context.Context, req *dto.AvatarGetReq) (res *dto.Avata
BodyTemplateId: a.BodyTemplateId,
SkinToneIndex: a.SkinToneIndex,
GlbUrl: a.GlbUrl,
FramesUrl: a.FramesUrl,
BuildStatus: a.BuildStatus,
Error: a.Error,
}, nil
+7
View File
@@ -2,6 +2,7 @@ package dao
import (
"context"
"strings"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/model/entity"
@@ -31,6 +32,12 @@ func init() {
if err != nil {
g.Log().Warningf(ctx, "create avatar_model table failed: %v", err)
}
// 旧库补列(CREATE TABLE IF NOT EXISTS 不给已存在表加列,忽略 duplicate column 错误)
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+consts.TableNameAvatarModel+" ADD COLUMN frames_url TEXT NOT NULL DEFAULT ''"); err != nil {
if !strings.Contains(err.Error(), "duplicate column") {
g.Log().Warningf(ctx, "alter avatar_model add frames_url failed: %v", err)
}
}
}
func (d *avatarModelDao) Insert(ctx context.Context, data *entity.AvatarModel) (int64, error) {
+1
View File
@@ -22,6 +22,7 @@ type AvatarGetRes struct {
BodyTemplateId int `json:"body_template_id"`
SkinToneIndex int `json:"skin_tone_index"`
GlbUrl string `json:"glb_url"`
FramesUrl string `json:"frames_url"`
BuildStatus string `json:"build_status"`
Error string `json:"error"`
}
+1
View File
@@ -10,6 +10,7 @@ type AvatarModel struct {
SkinToneIndex int `orm:"skin_tone_index" json:"skin_tone_index"`
FaceTextureUrl string `orm:"face_texture_url" json:"face_texture_url"`
GlbUrl string `orm:"glb_url" json:"glb_url"`
FramesUrl string `orm:"frames_url" json:"frames_url"`
BuildStatus string `orm:"build_status" json:"build_status"`
Error string `orm:"error" json:"error"`
ParamsSnapshot string `orm:"params_snapshot" json:"params_snapshot"`
+61 -32
View File
@@ -9,13 +9,22 @@ import (
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/entity"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
)
type avatarService struct{}
type avatarService struct {
renderFunc func(ctx context.Context, faceId, bodyId, skinIdx int) (string, error)
}
var AvatarService = new(avatarService)
// Build 构建化身(v1 同步简化:模板匹配 + 记录,异步管线后续接入)
func init() {
AvatarService.renderFunc = agent.RenderAvatarFrames
}
// Build 构建化身:模板匹配 + 写库(processing),异步预渲染帧序列
func (s *avatarService) Build(ctx context.Context, userId int64) (*entity.AvatarModel, error) {
photos, err := dao.UserPhoto.ListByUser(ctx, userId, 0)
if err != nil {
@@ -37,54 +46,74 @@ func (s *avatarService) Build(ctx context.Context, userId int64) (*entity.Avatar
return nil, errors.New("请先上传全身照")
}
bm, err := dao.BodyMeasurement.GetByUser(ctx, userId)
if err != nil {
return nil, err
}
bm, _ := dao.BodyMeasurement.GetByUser(ctx, userId) // 无测量记录时为 nil,用默认参数
feature := &agent.FaceFeature{SkinTone: 3, HeightCm: 170, WeightKg: 60}
if bm != nil {
feature = &agent.FaceFeature{SkinTone: bm.SkinTone, HeightCm: bm.Height, WeightKg: bm.Weight}
}
faceId, bodyId, skinIdx := agent.MatchTemplates(feature)
// 已有化身则重建(更新模板索引),否则新建
base := map[string]any{
"face_template_id": faceId, "body_template_id": bodyId,
"skin_tone_index": skinIdx, "glb_url": agent.PackGlbUrl(faceId, bodyId, skinIdx),
"build_status": consts.AvatarBuildProcessing, "error": "",
"params_snapshot": mustJSON(map[string]any{
"height_cm": feature.HeightCm, "weight_kg": feature.WeightKg, "skin_tone": skinIdx,
}),
}
var record *entity.AvatarModel
existing, _ := dao.AvatarModel.GetByUser(ctx, userId)
if existing != nil {
err := dao.AvatarModel.Update(ctx, existing.Id, map[string]any{
"face_template_id": faceId, "body_template_id": bodyId,
"skin_tone_index": skinIdx, "glb_url": agent.PackGlbUrl(faceId, bodyId, skinIdx),
"build_status": consts.AvatarBuildDone, "error": "",
"params_snapshot": mustJSON(map[string]any{
if err := dao.AvatarModel.Update(ctx, existing.Id, base); err != nil {
return nil, err
}
record = existing
} else {
id, err := dao.AvatarModel.Insert(ctx, &entity.AvatarModel{
UserId: userId,
FaceTemplateId: faceId,
BodyTemplateId: bodyId,
SkinToneIndex: skinIdx,
GlbUrl: agent.PackGlbUrl(faceId, bodyId, skinIdx),
BuildStatus: consts.AvatarBuildProcessing,
ParamsSnapshot: mustJSON(map[string]any{
"height_cm": feature.HeightCm, "weight_kg": feature.WeightKg, "skin_tone": skinIdx,
}),
"updated_at": "datetime('now','localtime')",
})
if err != nil {
return nil, err
}
existing.FaceTemplateId = faceId
existing.BodyTemplateId = bodyId
existing.SkinToneIndex = skinIdx
existing.GlbUrl = agent.PackGlbUrl(faceId, bodyId, skinIdx)
existing.BuildStatus = consts.AvatarBuildDone
return existing, nil
record = &entity.AvatarModel{Id: id, UserId: userId}
}
_, err = dao.AvatarModel.Insert(ctx, &entity.AvatarModel{
UserId: userId,
FaceTemplateId: faceId,
BodyTemplateId: bodyId,
SkinToneIndex: skinIdx,
GlbUrl: agent.PackGlbUrl(faceId, bodyId, skinIdx),
BuildStatus: consts.AvatarBuildDone,
ParamsSnapshot: mustJSON(map[string]any{
"height_cm": feature.HeightCm, "weight_kg": feature.WeightKg, "skin_tone": skinIdx,
}),
})
go s.renderJob(gctx.New(), record.Id, faceId, bodyId, skinIdx)
record.FaceTemplateId = faceId
record.BodyTemplateId = bodyId
record.SkinToneIndex = skinIdx
record.GlbUrl = agent.PackGlbUrl(faceId, bodyId, skinIdx)
record.BuildStatus = consts.AvatarBuildProcessing
return record, nil
}
// renderJob 异步预渲染帧序列:成功写 frames_url+done,失败写 error+failed(客户端降级为静态占位)
func (s *avatarService) renderJob(ctx context.Context, id int64, faceId, bodyId, skinIdx int) {
framesURL, err := s.renderFunc(ctx, faceId, bodyId, skinIdx)
if err != nil {
return nil, err
if dbErr := dao.AvatarModel.Update(ctx, id, map[string]any{
"build_status": consts.AvatarBuildFailed, "error": err.Error(),
"updated_at": "datetime('now','localtime')",
}); dbErr != nil {
g.Log().Warningf(ctx, "update avatar render failed state: %v", dbErr)
}
return
}
if dbErr := dao.AvatarModel.Update(ctx, id, map[string]any{
"frames_url": framesURL, "build_status": consts.AvatarBuildDone, "error": "",
"updated_at": "datetime('now','localtime')",
}); dbErr != nil {
g.Log().Warningf(ctx, "update avatar render done state: %v", dbErr)
}
return dao.AvatarModel.GetByUser(ctx, userId)
}
func (s *avatarService) Get(ctx context.Context, userId int64) (*entity.AvatarModel, error) {
@@ -0,0 +1,82 @@
package service
import (
"context"
"errors"
"testing"
"time"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/entity"
)
func avatarRecord(userId int64) *entity.AvatarModel {
return &entity.AvatarModel{
UserId: userId,
BuildStatus: consts.AvatarBuildProcessing,
Error: "",
}
}
func TestRenderJobSuccessStateMachine(t *testing.T) {
old := AvatarService.renderFunc
defer func() { AvatarService.renderFunc = old }()
AvatarService.renderFunc = func(_ context.Context, faceId, bodyId, skinIdx int) (string, error) {
return "/workspace/avatar_frames/f0_b1_s2", nil
}
userId := time.Now().UnixNano()
id, err := dao.AvatarModel.Insert(context.Background(), avatarRecord(userId))
if err != nil {
t.Fatalf("插入测试化身失败: %v", err)
}
AvatarService.renderJob(context.Background(), id, 0, 1, 2)
got, err := dao.AvatarModel.GetByUser(context.Background(), userId)
if err != nil || got == nil {
t.Fatalf("读取化身失败: %v", err)
}
if got.BuildStatus != consts.AvatarBuildDone {
t.Fatalf("渲染成功应置 done: got=%s", got.BuildStatus)
}
if got.FramesUrl != "/workspace/avatar_frames/f0_b1_s2" {
t.Fatalf("应写 frames_url: got=%q", got.FramesUrl)
}
if got.Error != "" {
t.Fatalf("成功时不应有 error: %q", got.Error)
}
}
func TestRenderJobFailedFallback(t *testing.T) {
old := AvatarService.renderFunc
defer func() { AvatarService.renderFunc = old }()
AvatarService.renderFunc = func(_ context.Context, _, _, _ int) (string, error) {
return "", errors.New("3D 渲染服务未就绪")
}
userId := time.Now().UnixNano() + 1
id, err := dao.AvatarModel.Insert(context.Background(), avatarRecord(userId))
if err != nil {
t.Fatalf("插入测试化身失败: %v", err)
}
AvatarService.renderJob(context.Background(), id, 0, 1, 2)
got, err := dao.AvatarModel.GetByUser(context.Background(), userId)
if err != nil || got == nil {
t.Fatalf("读取化身失败: %v", err)
}
if got.BuildStatus != consts.AvatarBuildFailed {
t.Fatalf("渲染失败应置 failed(降级): got=%s", got.BuildStatus)
}
if got.FramesUrl != "" {
t.Fatalf("失败时不应写 frames_url: got=%q", got.FramesUrl)
}
if got.Error == "" {
t.Fatal("失败时应记录错误文案")
}
}