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("失败时应记录错误文案") + } +}