Files
slogan/styleagent/agent/render.go
T
admin fd13efb9c0 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
2026-07-31 15:37:02 +08:00

100 lines
2.8 KiB
Go

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
}