This commit is contained in:
2026-08-03 10:29:31 +08:00
parent 878468cd6d
commit a338865025
20 changed files with 613 additions and 398 deletions
-138
View File
@@ -1,138 +0,0 @@
// 程序化生成简化人体化身 GLB(脸型/体型/肤色三参数组合)。
// 用法: node gen-templates.js --out <dir> [--faces 6] [--bodies 6] [--skins 5]
// 索引对齐 MatchTemplatesface 0 起、body/skin 1 起;已存在的 GLB 跳过(幂等)。
import { argv } from 'node:process';
import fs from 'node:fs';
import path from 'node:path';
import * as THREE from 'three';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
// Node 无 FileReaderGLTFExporter 二进制导出需要它(onloadend 回调风格)
if (typeof globalThis.FileReader === 'undefined') {
globalThis.FileReader = class {
constructor() { this.result = null; }
set onloadend(fn) { this._onloadend = fn; }
get onloadend() { return this._onloadend; }
readAsArrayBuffer(blob) {
blob.arrayBuffer().then((buf) => {
this.result = buf;
this._onloadend && this._onloadend();
});
}
};
}
function parseArgs() {
const a = { faces: 6, bodies: 6, skins: 5 };
for (let i = 2; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const val = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[++i] : true;
a[key] = val;
}
}
a.out = a.out || '.';
return a;
}
// 5 档肤色(PBR base color
const SKIN_TONES = ['#FDE4CF', '#F3C39B', '#D99B6C', '#A96B3F', '#6B4226'];
// 脸型变体:发型形状/颜色差异
const FACE_HAIR = [
{ style: 'short', color: '#2B2B2B' }, // 短发
{ style: 'fringe', color: '#4A3728' }, // 刘海长发
{ style: 'bob', color: '#8C5A2B' }, // 波波头
];
// 体型变体:宽度/高度比例
const BODY_VARIANTS = [
{ name: 'slim', width: 0.85, height: 1.0 },
{ name: 'normal', width: 1.0, height: 1.0 },
{ name: 'broad', width: 1.18, height: 0.96 },
];
function buildAvatar(faceIdx, bodyIdx, skinIdx) {
const face = FACE_HAIR[faceIdx % FACE_HAIR.length];
const body = BODY_VARIANTS[bodyIdx % BODY_VARIANTS.length];
const skin = SKIN_TONES[skinIdx % SKIN_TONES.length];
const root = new THREE.Group();
const skinMat = new THREE.MeshStandardMaterial({ color: skin, roughness: 0.7 });
const hairMat = new THREE.MeshStandardMaterial({ color: face.color, roughness: 0.85 });
const clothMat = new THREE.MeshStandardMaterial({ color: '#3D5A80', roughness: 0.8 });
const W = body.width;
const H = body.height;
// 躯干:胶囊体
const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.22 * W, 0.5, 8, 16), clothMat);
torso.position.y = 1.12 * H;
root.add(torso);
// 头:球体
const head = new THREE.Mesh(new THREE.SphereGeometry(0.16 * W, 24, 18), skinMat);
head.position.y = 1.68 * H;
root.add(head);
// 发型:覆盖头顶的半球壳(按脸型变体)
const hair = new THREE.Mesh(new THREE.SphereGeometry(0.175 * W, 24, 12, 0, Math.PI * 2, 0, Math.PI * 0.52), hairMat);
hair.position.y = 1.68 * H + 0.03;
root.add(hair);
if (face.style === 'fringe') {
const fringe = new THREE.Mesh(new THREE.BoxGeometry(0.16 * W, 0.05, 0.2 * W), hairMat);
fringe.position.y = 1.7 * H;
fringe.position.z = -0.12 * W;
fringe.rotation.x = -0.25;
root.add(fringe);
} else if (face.style === 'bob') {
const back = new THREE.Mesh(new THREE.BoxGeometry(0.3 * W, 0.3, 0.06), hairMat);
back.position.y = 1.52 * H;
back.position.z = 0.14 * W;
root.add(back);
}
// 上肢
for (const side of [-1, 1]) {
const arm = new THREE.Mesh(new THREE.CapsuleGeometry(0.075 * W, 0.42, 6, 10), skinMat);
arm.position.set(side * 0.32 * W, 1.32 * H, 0);
arm.rotation.z = side * 0.06;
root.add(arm);
}
// 下肢
for (const side of [-1, 1]) {
const leg = new THREE.Mesh(new THREE.CapsuleGeometry(0.1 * W, 0.62, 6, 10), clothMat);
leg.position.set(side * 0.12 * W, 0.55 * H, 0);
root.add(leg);
}
return root;
}
const args = parseArgs();
fs.mkdirSync(args.out, { recursive: true });
const exporter = new GLTFExporter();
let generated = 0;
for (let f = 0; f < args.faces; f++) {
for (let b = 1; b <= args.bodies; b++) {
for (let s = 1; s <= args.skins; s++) {
const name = `avatar_f${f}_b${b}_s${s}.glb`;
const outPath = path.join(args.out, name);
if (fs.existsSync(outPath)) continue;
const scene = buildAvatar(f, b, s);
const buf = await new Promise((resolve, reject) => {
exporter.parse(
scene,
(result) => resolve(Buffer.from(result)),
(err) => reject(err),
{ binary: true }
);
});
fs.writeFileSync(outPath, buf);
generated++;
}
}
}
console.log(`generated ${generated} GLB (skipped existing) -> ${args.out}`);
+114
View File
@@ -0,0 +1,114 @@
package main
// 为指定用户生成一套三视角全身照(真实调用 imagegen,非 mock):
// go run scripts/gen_user_photos/main.go [username]
// 默认用户 wenwu901。已存在同视角照片时跳过;图片存 workspace/user_{id}/photos/,记录写入 slogan_user_photo。
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/frame/g"
"slogan-agent/styleagent/agent"
"slogan-agent/styleagent/consts"
"slogan-agent/styleagent/dao"
"slogan-agent/styleagent/model/entity"
)
const personDesc = "一位穿浅蓝色衬衫与深灰色西裤的亚洲年轻女性,干净利落的黑色短发,身材匀称"
var views = []struct {
angle string
photoT int
prompt string
}{
{angle: "front", photoT: consts.PhotoTypeFullFront, prompt: personDesc + ",全身正面照,站直面对镜头,双手自然下垂,纯白背景,高清写实,全身入镜"},
{angle: "side", photoT: consts.PhotoTypeFullSide, prompt: personDesc + ",全身侧面照,侧身站立目视前方,纯白背景,高清写实,全身入镜"},
{angle: "back", photoT: consts.PhotoTypeFullBack, prompt: personDesc + ",全身背面照,背对镜头站立,纯白背景,高清写实,全身入镜"},
}
func main() {
username := "wenwu901"
if len(os.Args) > 1 {
username = os.Args[1]
}
ctx := context.Background()
var user entity.User
if err := g.DB().Model(consts.TableNameUser).Ctx(ctx).
Where("username", username).Scan(&user); err != nil || user.Id == 0 {
panic(fmt.Sprintf("用户 %s 不存在: %v", username, err))
}
fmt.Printf("用户: %s (id=%d)\n", username, user.Id)
existing, err := dao.UserPhoto.ListByUser(ctx, user.Id, 0)
if err != nil {
panic(err)
}
have := map[int]bool{}
for _, p := range existing {
have[p.Type] = true
}
client, err := agent.NewClient(g.Cfg().MustGet(ctx, "imagegen.supplier", "wanx").String())
if err != nil {
panic(err)
}
dir := filepath.Join("workspace", fmt.Sprintf("user_%d", user.Id), "photos")
if err := os.MkdirAll(dir, 0o755); err != nil {
panic(err)
}
// 三视角用同一 seed,保证人物一致
seed := time.Now().UnixNano() % 1_000_000
for _, v := range views {
if have[v.photoT] {
fmt.Printf("视角 %s 已有照片,跳过\n", v.angle)
continue
}
fmt.Printf("生成 %s 视角...\n", v.angle)
url, err := client.Generate(ctx, &agent.GenerateReq{
Prompt: v.prompt, Angle: v.angle, Seed: seed,
})
if err != nil {
panic(fmt.Sprintf("生成 %s 失败: %v", v.angle, err))
}
path := filepath.Join(dir, fmt.Sprintf("%d_%s.png", time.Now().UnixNano(), v.angle))
if err := download(url, path); err != nil {
panic(fmt.Sprintf("保存 %s 失败: %v", v.angle, err))
}
if _, err := dao.UserPhoto.Insert(ctx, &entity.UserPhoto{
UserId: user.Id, Type: v.photoT, Url: "/" + filepath.ToSlash(path), Status: 1,
}); err != nil {
panic(fmt.Sprintf("入库 %s 失败: %v", v.angle, err))
}
fmt.Printf("%s 完成: %s\n", v.angle, path)
}
fmt.Println("照片套生成完毕")
}
func download(url, dest string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("下载失败: http %d", resp.StatusCode)
}
out, err := os.Create(dest)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}