From fd13efb9c07daf470260ae976af95533b11d3ec3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com>
Date: Fri, 31 Jul 2026 15:37:02 +0800
Subject: [PATCH] =?UTF-8?q?feat:=203D=20=E5=8C=96=E8=BA=AB=E6=9C=8D?=
=?UTF-8?q?=E5=8A=A1=E7=AB=AF=E9=A2=84=E6=B8=B2=E6=9F=93=EF=BC=88Node=20?=
=?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=99=A8=20+=20=E5=B8=A7=E8=BD=AE=E6=92=AD?=
=?UTF-8?q?=20+=20=E9=99=8D=E7=BA=A7=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
.gitignore | 3 +
Dockerfile | 12 +-
config.yml | 5 +
scripts/avatar-render/gen-templates.js | 138 ++++
scripts/avatar-render/package-lock.json | 713 ++++++++++++++++++
scripts/avatar-render/package.json | 12 +
scripts/avatar-render/render.js | 115 +++
styleagent/agent/render.go | 99 +++
styleagent/controller/avatar_controller.go | 1 +
styleagent/dao/avatar_model_dao.go | 7 +
styleagent/model/dto/avatar_model_dto.go | 1 +
styleagent/model/entity/avatar_model.go | 1 +
styleagent/service/avatar_model_service.go | 93 ++-
.../service/avatar_model_service_test.go | 82 ++
14 files changed, 1249 insertions(+), 33 deletions(-)
create mode 100644 scripts/avatar-render/gen-templates.js
create mode 100644 scripts/avatar-render/package-lock.json
create mode 100644 scripts/avatar-render/package.json
create mode 100644 scripts/avatar-render/render.js
create mode 100644 styleagent/agent/render.go
create mode 100644 styleagent/service/avatar_model_service_test.go
diff --git a/.gitignore b/.gitignore
index 4dab7b4..0bba3ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,6 @@ workspace/
# 系统
.DS_Store
+
+# 渲染器依赖
+scripts/avatar-render/node_modules/
diff --git a/Dockerfile b/Dockerfile
index efcea79..7505627 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"]
diff --git a/config.yml b/config.yml
index 7901109..b750337 100644
--- a/config.yml
+++ b/config.yml
@@ -51,3 +51,8 @@ payment:
ad:
limit_effect_extra: 2
limit_vip_trial: 1
+
+# 3D 化身预渲染(Node + headless-gl,node_bin 需指向 gl 有预编译二进制的 Node 版本)
+render:
+ enabled: true
+ node_bin: "/Users/zhangbin/.nvm/versions/node/v18.20.4/bin/node"
diff --git a/scripts/avatar-render/gen-templates.js b/scripts/avatar-render/gen-templates.js
new file mode 100644
index 0000000..cf32a3e
--- /dev/null
+++ b/scripts/avatar-render/gen-templates.js
@@ -0,0 +1,138 @@
+// 程序化生成简化人体化身 GLB(脸型/体型/肤色三参数组合)。
+// 用法: node gen-templates.js --out
[--faces 6] [--bodies 6] [--skins 5]
+// 索引对齐 MatchTemplates:face 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 无 FileReader,GLTFExporter 二进制导出需要它(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}`);
diff --git a/scripts/avatar-render/package-lock.json b/scripts/avatar-render/package-lock.json
new file mode 100644
index 0000000..1f63996
--- /dev/null
+++ b/scripts/avatar-render/package-lock.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/scripts/avatar-render/package.json b/scripts/avatar-render/package.json
new file mode 100644
index 0000000..b6a7337
--- /dev/null
+++ b/scripts/avatar-render/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "avatar-render",
+ "version": "1.0.0",
+ "private": true,
+ "description": "服务端 3D 化身预渲染:GLB -> 36 帧旋转 PNG(three.js + headless-gl + pngjs)",
+ "type": "module",
+ "dependencies": {
+ "gl": "^9.0.0-rc.10",
+ "pngjs": "^7.0.0",
+ "three": "0.162.0"
+ }
+}
diff --git a/scripts/avatar-render/render.js b/scripts/avatar-render/render.js
new file mode 100644
index 0000000..ceac7c3
--- /dev/null
+++ b/scripts/avatar-render/render.js
@@ -0,0 +1,115 @@
+// 化身 GLB -> 36 帧旋转 PNG(绕 Y 轴 10° 步进),服务端预渲染。
+// 用法: node render.js --glb --out [--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 --out [--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);
+}
diff --git a/styleagent/agent/render.go b/styleagent/agent/render.go
new file mode 100644
index 0000000..2439c97
--- /dev/null
+++ b/styleagent/agent/render.go
@@ -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
+}
diff --git a/styleagent/controller/avatar_controller.go b/styleagent/controller/avatar_controller.go
index 7de7cdf..025b5f8 100644
--- a/styleagent/controller/avatar_controller.go
+++ b/styleagent/controller/avatar_controller.go
@@ -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
diff --git a/styleagent/dao/avatar_model_dao.go b/styleagent/dao/avatar_model_dao.go
index ef0e6b7..9304135 100644
--- a/styleagent/dao/avatar_model_dao.go
+++ b/styleagent/dao/avatar_model_dao.go
@@ -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) {
diff --git a/styleagent/model/dto/avatar_model_dto.go b/styleagent/model/dto/avatar_model_dto.go
index 150a7aa..e4eb805 100644
--- a/styleagent/model/dto/avatar_model_dto.go
+++ b/styleagent/model/dto/avatar_model_dto.go
@@ -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"`
}
diff --git a/styleagent/model/entity/avatar_model.go b/styleagent/model/entity/avatar_model.go
index 8e7ea82..a0f8a7b 100644
--- a/styleagent/model/entity/avatar_model.go
+++ b/styleagent/model/entity/avatar_model.go
@@ -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"`
diff --git a/styleagent/service/avatar_model_service.go b/styleagent/service/avatar_model_service.go
index 5af4e15..4cb4dde 100644
--- a/styleagent/service/avatar_model_service.go
+++ b/styleagent/service/avatar_model_service.go
@@ -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) {
diff --git a/styleagent/service/avatar_model_service_test.go b/styleagent/service/avatar_model_service_test.go
new file mode 100644
index 0000000..642dcb8
--- /dev/null
+++ b/styleagent/service/avatar_model_service_test.go
@@ -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("失败时应记录错误文案")
+ }
+}