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:
@@ -11,3 +11,6 @@ workspace/
|
||||
|
||||
# 系统
|
||||
.DS_Store
|
||||
|
||||
# 渲染器依赖
|
||||
scripts/avatar-render/node_modules/
|
||||
|
||||
+11
-1
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// 程序化生成简化人体化身 GLB(脸型/体型/肤色三参数组合)。
|
||||
// 用法: node gen-templates.js --out <dir> [--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}`);
|
||||
Generated
+713
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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("失败时应记录错误文案")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user