@forgeax-extension/character-3d 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SOURCE_PROVENANCE.md +8 -0
- package/dist/assets/index-CIx3aXo1.js +4336 -0
- package/dist/assets/index-CIx3aXo1.js.map +1 -0
- package/dist/assets/index-CmhOHLEB.css +1 -0
- package/dist/hdr/README.md +28 -0
- package/dist/hdr/presets.json +8 -0
- package/dist/index.html +13 -0
- package/forgeax-extension.json +428 -0
- package/marketplace-card.json +56 -0
- package/package.json +59 -0
- package/packages/external-asset-meta/package.json +24 -0
- package/packages/external-asset-meta/src/cook.test.ts +211 -0
- package/packages/external-asset-meta/src/cook.ts +188 -0
- package/packages/external-asset-meta/src/draco3dgltf.d.ts +11 -0
- package/packages/external-asset-meta/src/index.ts +18 -0
- package/packages/external-asset-meta/src/normalize.test.ts +36 -0
- package/packages/external-asset-meta/src/normalize.ts +101 -0
- package/packages/external-asset-meta/src/types.ts +82 -0
- package/schemas/adopt-playable-character.args.json +26 -0
- package/schemas/adopt-playable-character.returns.json +31 -0
- package/schemas/apply-motion.args.json +47 -0
- package/schemas/apply-motion.returns.json +23 -0
- package/schemas/auto-rig.args.json +35 -0
- package/schemas/auto-rig.returns.json +23 -0
- package/schemas/delete-asset.args.json +18 -0
- package/schemas/delete-asset.returns.json +18 -0
- package/schemas/engine-import-status.args.json +18 -0
- package/schemas/engine-import-status.returns.json +50 -0
- package/schemas/export-playable-character.args.json +21 -0
- package/schemas/export-playable-character.returns.json +47 -0
- package/schemas/gen3d-asset-manifest.json +281 -0
- package/schemas/generate-meshy-text-mock.args.json +42 -0
- package/schemas/generate-meshy-text-mock.returns.json +301 -0
- package/schemas/get-credentials.args.json +7 -0
- package/schemas/get-credentials.returns.json +45 -0
- package/schemas/get-playable-profile.args.json +11 -0
- package/schemas/get-playable-profile.returns.json +188 -0
- package/schemas/image-to-3d.args.json +47 -0
- package/schemas/image-to-3d.returns.json +306 -0
- package/schemas/import-to-engine.args.json +18 -0
- package/schemas/import-to-engine.returns.json +35 -0
- package/schemas/list-assets.args.json +22 -0
- package/schemas/list-assets.returns.json +296 -0
- package/schemas/list-motions.args.json +29 -0
- package/schemas/list-motions.returns.json +41 -0
- package/schemas/pose-standardization.args.json +28 -0
- package/schemas/pose-standardization.returns.json +29 -0
- package/schemas/provider-status.args.json +7 -0
- package/schemas/provider-status.returns.json +70 -0
- package/schemas/refine-mesh.args.json +33 -0
- package/schemas/refine-mesh.returns.json +306 -0
- package/schemas/rename-asset.args.json +22 -0
- package/schemas/rename-asset.returns.json +14 -0
- package/schemas/retopo-lowpoly.args.json +34 -0
- package/schemas/retopo-lowpoly.returns.json +24 -0
- package/schemas/score-quality.args.json +36 -0
- package/schemas/score-quality.returns.json +12 -0
- package/schemas/set-credentials.args.json +32 -0
- package/schemas/set-credentials.returns.json +45 -0
- package/schemas/set-playable-motion-mapping.args.json +25 -0
- package/schemas/set-playable-motion-mapping.returns.json +33 -0
- package/schemas/set-playable-profile.args.json +32 -0
- package/schemas/set-playable-profile.returns.json +67 -0
- package/schemas/text-to-3d.args.json +51 -0
- package/schemas/text-to-3d.returns.json +306 -0
- package/schemas/upload-image.args.json +19 -0
- package/schemas/upload-image.returns.json +21 -0
- package/schemas/upload-video.args.json +14 -0
- package/schemas/upload-video.returns.json +14 -0
- package/schemas/views-to-3d.args.json +58 -0
- package/schemas/views-to-3d.returns.json +306 -0
- package/server/adopt-playable-character.ts +291 -0
- package/server/asset-storage.ts +136 -0
- package/server/asset-upload.ts +123 -0
- package/server/audit.ts +58 -0
- package/server/cache.ts +75 -0
- package/server/cos-uploader.ts +141 -0
- package/server/credentials-store.ts +186 -0
- package/server/engine-import.ts +238 -0
- package/server/env.ts +287 -0
- package/server/export-playable-character.ts +364 -0
- package/server/generate.ts +89 -0
- package/server/merge-playable-character.ts +259 -0
- package/server/motion-catalog.ts +137 -0
- package/server/per-game-store.ts +1016 -0
- package/server/providers/gateway-client.ts +171 -0
- package/server/providers/gateway-data.ts +182 -0
- package/server/providers/gateway-models.ts +49 -0
- package/server/providers/hunyuan-rest.ts +317 -0
- package/server/providers/hunyuan-workflow.ts +225 -0
- package/server/providers/meshy-direct-client.ts +223 -0
- package/server/providers/meshy.ts +548 -0
- package/server/providers/rodin-gateway.ts +193 -0
- package/server/providers/rodin.ts +253 -0
- package/server/providers/visvise.ts +329 -0
- package/server/rate-guard.ts +29 -0
- package/server/tool-handlers.ts +2193 -0
- package/shared/catalog.ts +307 -0
- package/shared/manifest.ts +378 -0
- package/shared/meshy-actions.ts +690 -0
- package/shared/playable-preview-url.test.ts +14 -0
- package/shared/playable-preview-url.ts +13 -0
- package/shared/playable-profile.test.ts +127 -0
- package/shared/playable-profile.ts +171 -0
- package/shared/provider-params.test.ts +172 -0
- package/shared/provider-params.ts +389 -0
- package/shared/quality/heuristics.test.ts +84 -0
- package/shared/quality/heuristics.ts +100 -0
- package/shared/rodin-image.test.ts +51 -0
- package/shared/rodin-image.ts +125 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Generation orchestration — turns a pure ProviderResult into a durable per-game
|
|
2
|
+
// asset (main GLB + sidefiles + sidecar) via the AssetStorage adapter. This is
|
|
3
|
+
// the layer that knows about both providers and storage; providers and the
|
|
4
|
+
// store stay unaware of each other (ADR-0001 decoupling). Identity is the
|
|
5
|
+
// game-relative assetPath (ADR-0002), not a random UUID.
|
|
6
|
+
|
|
7
|
+
import type { ProviderResult } from '../shared/catalog';
|
|
8
|
+
import type { AssetSlot, Gen3DAssetManifest } from '../shared/manifest';
|
|
9
|
+
import type { AssetFileInput, AssetStorage } from './asset-storage';
|
|
10
|
+
import * as cache from './cache';
|
|
11
|
+
import { audit } from './audit';
|
|
12
|
+
|
|
13
|
+
export interface PersistInput {
|
|
14
|
+
slug: string;
|
|
15
|
+
assetSlot: AssetSlot;
|
|
16
|
+
assetName: string;
|
|
17
|
+
faceCount?: number;
|
|
18
|
+
cacheKey: string;
|
|
19
|
+
sourceInputAssetPaths?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function persistGeneration(
|
|
23
|
+
result: ProviderResult,
|
|
24
|
+
storage: AssetStorage,
|
|
25
|
+
ctx: PersistInput,
|
|
26
|
+
): Promise<Gen3DAssetManifest> {
|
|
27
|
+
const files: AssetFileInput[] = result.files.map((f) => ({
|
|
28
|
+
data: f.data,
|
|
29
|
+
format: f.format,
|
|
30
|
+
role: f.role,
|
|
31
|
+
}));
|
|
32
|
+
return storage.writeAsset({
|
|
33
|
+
slug: ctx.slug,
|
|
34
|
+
assetSlot: ctx.assetSlot,
|
|
35
|
+
assetName: ctx.assetName,
|
|
36
|
+
files,
|
|
37
|
+
meta: {
|
|
38
|
+
provider: result.provider,
|
|
39
|
+
providerMode: result.providerMode,
|
|
40
|
+
mode: result.mode,
|
|
41
|
+
sourceJobId: result.sourceJobId,
|
|
42
|
+
prompt: result.prompt,
|
|
43
|
+
sourceInputAssetPaths: ctx.sourceInputAssetPaths ?? [],
|
|
44
|
+
...(ctx.faceCount !== undefined ? { faceCount: ctx.faceCount } : {}),
|
|
45
|
+
cacheKey: ctx.cacheKey,
|
|
46
|
+
...(result.meshyTaskRefs ? { meshyTaskRefs: result.meshyTaskRefs } : {}),
|
|
47
|
+
...(result.visviseRefs ? { visviseRefs: result.visviseRefs } : {}),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CachedGenerationResult {
|
|
53
|
+
manifest: Gen3DAssetManifest;
|
|
54
|
+
cacheHit: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Cache-first generation. On a cacheKey hit, returns the existing manifest
|
|
58
|
+
// (loaded from the per-game store by assetPath, never a stale provider URL).
|
|
59
|
+
// A cache hit reuses the existing path and ignores any freshly-typed assetName.
|
|
60
|
+
// Otherwise runs the producer, persists a durable per-game asset, then records
|
|
61
|
+
// cacheKey -> assetPath only after the full success (write-after-success).
|
|
62
|
+
export async function generateCacheFirst(
|
|
63
|
+
storage: AssetStorage,
|
|
64
|
+
ctx: PersistInput,
|
|
65
|
+
produce: () => Promise<ProviderResult>,
|
|
66
|
+
): Promise<CachedGenerationResult> {
|
|
67
|
+
const cachedPath = await cache.lookup(ctx.slug, ctx.cacheKey);
|
|
68
|
+
if (cachedPath) {
|
|
69
|
+
const existing = await storage.getAsset(ctx.slug, cachedPath);
|
|
70
|
+
if (existing) {
|
|
71
|
+
await audit(ctx.slug, {
|
|
72
|
+
ts: new Date().toISOString(),
|
|
73
|
+
provider: existing.provider,
|
|
74
|
+
mode: existing.mode,
|
|
75
|
+
event: 'cache_hit',
|
|
76
|
+
cacheKey: ctx.cacheKey,
|
|
77
|
+
assetPath: cachedPath,
|
|
78
|
+
});
|
|
79
|
+
return { manifest: existing, cacheHit: true };
|
|
80
|
+
}
|
|
81
|
+
// Mapping points at a missing file (deleted without tombstone); fall through
|
|
82
|
+
// and regenerate rather than crash.
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const result = await produce();
|
|
86
|
+
const manifest = await persistGeneration(result, storage, ctx);
|
|
87
|
+
await cache.remember(ctx.slug, ctx.cacheKey, manifest.assetPath);
|
|
88
|
+
return { manifest, cacheHit: false };
|
|
89
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
|
|
2
|
+
// mergePlayableCharacter — merge rigged base + mapped motion GLBs into one
|
|
3
|
+
// multi-clip playable GLB (PLAN §5.5 / ANIM2 / MAP3 / F1).
|
|
4
|
+
// Pure function over bytes — no disk I/O. Logic adapted from hellforge
|
|
5
|
+
// scripts/merge-gen3d-motions.ts (inlined; do not import games/).
|
|
6
|
+
|
|
7
|
+
import { Document, NodeIO, VertexLayout, type Animation, type Node } from '@gltf-transform/core';
|
|
8
|
+
import { mergeDocuments, prune, unpartition } from '@gltf-transform/functions';
|
|
9
|
+
import type { RootMotionStrategy } from '../shared/playable-profile';
|
|
10
|
+
|
|
11
|
+
export interface MergeSlotInput {
|
|
12
|
+
slotId: string;
|
|
13
|
+
motionGlbBytes: Uint8Array;
|
|
14
|
+
rootMotion: RootMotionStrategy;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MergePlayableInput {
|
|
18
|
+
baseRiggedGlbBytes: Uint8Array;
|
|
19
|
+
/** Already filtered: required mapped + filled optional; stable export order. */
|
|
20
|
+
slots: readonly MergeSlotInput[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type MergePlayableErrorCode =
|
|
24
|
+
| 'empty_slots'
|
|
25
|
+
| 'base_read_failed'
|
|
26
|
+
| 'motion_read_failed'
|
|
27
|
+
| 'motion_clip_count'
|
|
28
|
+
| 'root_joint_missing'
|
|
29
|
+
| 'write_failed'
|
|
30
|
+
| 'validation_failed';
|
|
31
|
+
|
|
32
|
+
export type MergePlayableResult =
|
|
33
|
+
| { ok: true; bytes: Uint8Array; clipNames: string[] }
|
|
34
|
+
| { ok: false; code: MergePlayableErrorCode; message: string; slotId?: string };
|
|
35
|
+
|
|
36
|
+
const ROOT_JOINT_RE = /^(hips|root)$/i;
|
|
37
|
+
|
|
38
|
+
function findRootJoint(nodes: readonly Node[]): Node | null {
|
|
39
|
+
for (const n of nodes) {
|
|
40
|
+
const name = n.getName() || '';
|
|
41
|
+
if (ROOT_JOINT_RE.test(name.trim())) return n;
|
|
42
|
+
}
|
|
43
|
+
// Fallback: any node whose name contains hips/root as a word-ish substring.
|
|
44
|
+
for (const n of nodes) {
|
|
45
|
+
const name = (n.getName() || '').toLowerCase();
|
|
46
|
+
if (/(^|[^a-z])(hips|root)([^a-z]|$)/i.test(name)) return n;
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Apply root-motion strategy to translation channels targeting Hips|Root.
|
|
53
|
+
* Mutates `anim` in place. Returns false when strategy needs a joint but none found.
|
|
54
|
+
*/
|
|
55
|
+
export function applyRootMotionStrategy(anim: Animation, strategy: RootMotionStrategy, joints: readonly Node[]): boolean {
|
|
56
|
+
if (strategy === 'preserve') return true;
|
|
57
|
+
const root = findRootJoint(joints);
|
|
58
|
+
if (!root) return false;
|
|
59
|
+
|
|
60
|
+
for (const channel of anim.listChannels()) {
|
|
61
|
+
if (channel.getTargetPath() !== 'translation') continue;
|
|
62
|
+
if (channel.getTargetNode() !== root) continue;
|
|
63
|
+
const sampler = channel.getSampler();
|
|
64
|
+
if (!sampler) continue;
|
|
65
|
+
const output = sampler.getOutput();
|
|
66
|
+
if (!output) continue;
|
|
67
|
+
const arr = output.getArray();
|
|
68
|
+
if (!arr || arr.length < 3) continue;
|
|
69
|
+
const next = new Float32Array(arr.length);
|
|
70
|
+
next.set(arr as Float32Array);
|
|
71
|
+
for (let i = 0; i + 2 < next.length; i += 3) {
|
|
72
|
+
if (strategy === 'remove_xz' || strategy === 'remove_xyz') {
|
|
73
|
+
next[i] = 0; // X
|
|
74
|
+
next[i + 2] = 0; // Z
|
|
75
|
+
}
|
|
76
|
+
if (strategy === 'remove_xyz') {
|
|
77
|
+
next[i + 1] = 0; // Y
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
output.setArray(next);
|
|
81
|
+
}
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateMerged(doc: Document, expectedClips: number): string | null {
|
|
86
|
+
const root = doc.getRoot();
|
|
87
|
+
if (root.listMeshes().length < 1) return 'merged GLB has no meshes';
|
|
88
|
+
if (root.listSkins().length < 1) return 'merged GLB has no skin';
|
|
89
|
+
// Engine emits skeleton 1:1 with skins; require ≥1 skin-backed hierarchy.
|
|
90
|
+
if (root.listAnimations().length !== expectedClips) {
|
|
91
|
+
return `expected ${expectedClips} animation clips, got ${root.listAnimations().length}`;
|
|
92
|
+
}
|
|
93
|
+
// Skinned nodes should sit at unit world transform (PLAN §5.5).
|
|
94
|
+
for (const node of root.listNodes()) {
|
|
95
|
+
if (!node.getSkin()) continue;
|
|
96
|
+
const t = node.getTranslation();
|
|
97
|
+
const r = node.getRotation();
|
|
98
|
+
const s = node.getScale();
|
|
99
|
+
const near = (a: number, b: number) => Math.abs(a - b) < 1e-3;
|
|
100
|
+
const unitT = near(t[0], 0) && near(t[1], 0) && near(t[2], 0);
|
|
101
|
+
const unitR = near(r[0], 0) && near(r[1], 0) && near(r[2], 0) && near(r[3], 1);
|
|
102
|
+
const unitS = near(s[0], 1) && near(s[1], 1) && near(s[2], 1);
|
|
103
|
+
if (!unitT || !unitR || !unitS) {
|
|
104
|
+
return `skinned node ${JSON.stringify(node.getName())} is not at unit world TRS`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Merge rigged base + per-slot motion GLBs into one engine-compatible GLB.
|
|
112
|
+
* Callers must enforce F1 (missing required slots) BEFORE invoking this.
|
|
113
|
+
*/
|
|
114
|
+
export async function mergePlayableCharacter(input: MergePlayableInput): Promise<MergePlayableResult> {
|
|
115
|
+
if (input.slots.length === 0) {
|
|
116
|
+
return { ok: false, code: 'empty_slots', message: 'No slots to export (add ≥1 required mapped slot).' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const io = new NodeIO();
|
|
120
|
+
io.setVertexLayout(VertexLayout.SEPARATE);
|
|
121
|
+
|
|
122
|
+
let base: Document;
|
|
123
|
+
try {
|
|
124
|
+
base = await io.readBinary(input.baseRiggedGlbBytes);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
code: 'base_read_failed',
|
|
129
|
+
message: err instanceof Error ? err.message : 'Failed to read rigged base GLB',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const baseOriginalNodes = new Set<Node>();
|
|
134
|
+
const baseNodesByName = new Map<string, Node>();
|
|
135
|
+
for (const n of base.getRoot().listNodes()) {
|
|
136
|
+
baseOriginalNodes.add(n);
|
|
137
|
+
const name = n.getName();
|
|
138
|
+
if (name) baseNodesByName.set(name, n);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Drop base rest/bind clips (PLAN §5.5).
|
|
142
|
+
for (const anim of base.getRoot().listAnimations()) anim.dispose();
|
|
143
|
+
|
|
144
|
+
for (const slot of input.slots) {
|
|
145
|
+
let motion: Document;
|
|
146
|
+
try {
|
|
147
|
+
motion = await io.readBinary(slot.motionGlbBytes);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
code: 'motion_read_failed',
|
|
152
|
+
message: err instanceof Error ? err.message : `Failed to read motion for slot ${slot.slotId}`,
|
|
153
|
+
slotId: slot.slotId,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const clips = motion.getRoot().listAnimations();
|
|
157
|
+
if (clips.length !== 1) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
code: 'motion_clip_count',
|
|
161
|
+
message: `Slot ${slot.slotId}: motion must contain exactly 1 animation clip, got ${clips.length}`,
|
|
162
|
+
slotId: slot.slotId,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
clips[0]!.setName(slot.slotId);
|
|
166
|
+
mergeDocuments(base, motion);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Remap channel targets from motion-copied joints → base skeleton joints.
|
|
170
|
+
for (const anim of base.getRoot().listAnimations()) {
|
|
171
|
+
for (const ch of anim.listChannels()) {
|
|
172
|
+
const t = ch.getTargetNode();
|
|
173
|
+
if (t && !baseOriginalNodes.has(t)) {
|
|
174
|
+
const name = t.getName();
|
|
175
|
+
const rep = name ? baseNodesByName.get(name) : undefined;
|
|
176
|
+
if (rep) ch.setTargetNode(rep);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Drop extra scenes + motion-copied nodes.
|
|
182
|
+
const scenes = base.getRoot().listScenes();
|
|
183
|
+
for (let i = 1; i < scenes.length; i++) scenes[i]!.dispose();
|
|
184
|
+
for (const n of base.getRoot().listNodes()) {
|
|
185
|
+
if (!baseOriginalNodes.has(n)) n.dispose();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Apply per-slot root motion AFTER remap, using base joints.
|
|
189
|
+
const baseJoints = base.getRoot().listNodes();
|
|
190
|
+
for (const anim of base.getRoot().listAnimations()) {
|
|
191
|
+
const slot = input.slots.find((s) => s.slotId === anim.getName());
|
|
192
|
+
if (!slot) continue;
|
|
193
|
+
const ok = applyRootMotionStrategy(anim, slot.rootMotion, baseJoints);
|
|
194
|
+
if (!ok) {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
code: 'root_joint_missing',
|
|
198
|
+
message: `Slot ${slot.slotId}: root motion ${slot.rootMotion} needs a Hips|Root joint, none found`,
|
|
199
|
+
slotId: slot.slotId,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await base.transform(prune(), unpartition());
|
|
205
|
+
|
|
206
|
+
const clipNames = base.getRoot().listAnimations().map((a) => a.getName());
|
|
207
|
+
const validationError = validateMerged(base, input.slots.length);
|
|
208
|
+
if (validationError) {
|
|
209
|
+
return { ok: false, code: 'validation_failed', message: validationError };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const bytes = await io.writeBinary(base);
|
|
214
|
+
return { ok: true, bytes, clipNames };
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
code: 'write_failed',
|
|
219
|
+
message: err instanceof Error ? err.message : 'Failed to write merged GLB',
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Resolve which slots will actually export (F1 / empty optional). */
|
|
225
|
+
export function resolveExportSlots(args: {
|
|
226
|
+
slots: readonly { slotId: string; required: boolean; rootMotion: RootMotionStrategy }[];
|
|
227
|
+
mappings: readonly { slotId: string; motionRefKey: string | null }[];
|
|
228
|
+
}):
|
|
229
|
+
| { ok: true; exportSlotIds: string[]; mappingBySlot: Map<string, string> }
|
|
230
|
+
| { ok: false; code: 'missing_required' | 'no_required_slots'; message: string; missingSlots: string[] } {
|
|
231
|
+
const map = new Map(args.mappings.map((m) => [m.slotId, m.motionRefKey]));
|
|
232
|
+
const required = args.slots.filter((s) => s.required);
|
|
233
|
+
if (required.length === 0) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
code: 'no_required_slots',
|
|
237
|
+
message: 'Profile has no required slots (PROF5: add ≥1 required slot before export).',
|
|
238
|
+
missingSlots: [],
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const missing = required.filter((s) => !map.get(s.slotId)).map((s) => s.slotId);
|
|
242
|
+
if (missing.length > 0) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
code: 'missing_required',
|
|
246
|
+
message: `Missing required motion slots: ${missing.join(', ')}`,
|
|
247
|
+
missingSlots: missing,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const exportSlotIds: string[] = [];
|
|
251
|
+
const mappingBySlot = new Map<string, string>();
|
|
252
|
+
for (const s of args.slots) {
|
|
253
|
+
const key = map.get(s.slotId) ?? null;
|
|
254
|
+
if (!key) continue; // empty optional — omit
|
|
255
|
+
exportSlotIds.push(s.slotId);
|
|
256
|
+
mappingBySlot.set(s.slotId, key);
|
|
257
|
+
}
|
|
258
|
+
return { ok: true, exportSlotIds, mappingBySlot };
|
|
259
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Motion catalog — discovery layer for the apply-motion UI + the
|
|
2
|
+
// gen3d:list-motions tool (ADR-0006 / PLAN §8-Q1,Q1b). It wraps
|
|
3
|
+
// MeshyProvider.listActions with a small TTL cache, supplies the Hunyuan v1
|
|
4
|
+
// fixed set, a quota-safe mock sample, and filtering. Listing actions is a GET
|
|
5
|
+
// (zero credits); with no Meshy env it returns a deterministic mock sample so
|
|
6
|
+
// the browser still works offline. The AI-facing schema never enumerates the
|
|
7
|
+
// ~680 actions — callers filter via query/category/rigType (PLAN §8-Q1b).
|
|
8
|
+
|
|
9
|
+
import { getMeshyEnv } from './env';
|
|
10
|
+
import { MeshyProvider } from './providers/meshy';
|
|
11
|
+
import {
|
|
12
|
+
HUNYUAN_V1_MOTION_LABELS,
|
|
13
|
+
type MotionSystem,
|
|
14
|
+
type MotionType,
|
|
15
|
+
} from '../shared/manifest';
|
|
16
|
+
|
|
17
|
+
// One selectable motion across systems. Rich metadata (category / rigType /
|
|
18
|
+
// isFree / preview gif) lives here, resolved on demand by (system,id); it is
|
|
19
|
+
// NOT persisted onto assets (PLAN §3-1).
|
|
20
|
+
export interface MotionOption {
|
|
21
|
+
system: MotionSystem;
|
|
22
|
+
id: number | string;
|
|
23
|
+
label: string;
|
|
24
|
+
category: string | null;
|
|
25
|
+
rigType: string | null;
|
|
26
|
+
isFree: boolean;
|
|
27
|
+
previewGifUrl: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const HUNYUAN_V1_IDS: readonly MotionType[] = [9, 10, 11, 12, 13, 14, 15, 16];
|
|
31
|
+
|
|
32
|
+
// Hunyuan v1 fixed motions, for assets rigged via the internal Hunyuan path.
|
|
33
|
+
export function hunyuanV1Catalog(): MotionOption[] {
|
|
34
|
+
return HUNYUAN_V1_IDS.map((id) => ({
|
|
35
|
+
system: 'hunyuan_v1' as const,
|
|
36
|
+
id,
|
|
37
|
+
label: HUNYUAN_V1_MOTION_LABELS[id],
|
|
38
|
+
category: 'hunyuan v1',
|
|
39
|
+
rigType: null,
|
|
40
|
+
isFree: false,
|
|
41
|
+
previewGifUrl: null,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function visviseCatalog(): MotionOption[] {
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
system: 'visvise',
|
|
49
|
+
id: 'text',
|
|
50
|
+
label: 'Text motion',
|
|
51
|
+
category: 'visvise',
|
|
52
|
+
rigType: null,
|
|
53
|
+
isFree: false,
|
|
54
|
+
previewGifUrl: null,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
system: 'visvise',
|
|
58
|
+
id: 'video',
|
|
59
|
+
label: 'Video motion',
|
|
60
|
+
category: 'visvise',
|
|
61
|
+
rigType: null,
|
|
62
|
+
isFree: false,
|
|
63
|
+
previewGifUrl: null,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Deterministic no-quota sample so the motion browser renders without a key.
|
|
69
|
+
const MOCK_MESHY_MOTIONS: MotionOption[] = [
|
|
70
|
+
{ system: 'meshy', id: 28, label: 'Big Wave Hello', category: 'gesture', rigType: 'style_02', isFree: true, previewGifUrl: null },
|
|
71
|
+
{ system: 'meshy', id: 101, label: 'Walk', category: 'locomotion', rigType: 'style_02', isFree: true, previewGifUrl: null },
|
|
72
|
+
{ system: 'meshy', id: 102, label: 'Run', category: 'locomotion', rigType: 'style_02', isFree: false, previewGifUrl: null },
|
|
73
|
+
{ system: 'meshy', id: 201, label: 'Jump', category: 'locomotion', rigType: 'style_02', isFree: false, previewGifUrl: null },
|
|
74
|
+
{ system: 'meshy', id: 305, label: 'Idle Dance', category: 'expression', rigType: 'style_02', isFree: false, previewGifUrl: null },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const CATALOG_TTL_MS = 10 * 60_000;
|
|
78
|
+
const CATALOG_KEY = '__public__';
|
|
79
|
+
interface CacheEntry {
|
|
80
|
+
at: number;
|
|
81
|
+
options: MotionOption[];
|
|
82
|
+
}
|
|
83
|
+
const meshyCache = new Map<string, CacheEntry>();
|
|
84
|
+
|
|
85
|
+
// Fetch (and TTL-cache) the public Meshy motion catalog (~680 actions; the only
|
|
86
|
+
// catalog endpoint — rigType compatibility is filtered client-side via the
|
|
87
|
+
// returned `rigType` field). Only positive action ids are surfaced: apply-motion
|
|
88
|
+
// drives /animations by a positive action_id, while the bundled free walk/run
|
|
89
|
+
// (negative sentinel ids) are delivered at rig time, not via this catalog.
|
|
90
|
+
// Quota-safe: no env → deterministic mock sample.
|
|
91
|
+
export async function getMeshyCatalog(
|
|
92
|
+
slug: string,
|
|
93
|
+
): Promise<{ usedMock: boolean; options: MotionOption[] }> {
|
|
94
|
+
const env = getMeshyEnv();
|
|
95
|
+
if (!env) return { usedMock: true, options: MOCK_MESHY_MOTIONS };
|
|
96
|
+
const hit = meshyCache.get(CATALOG_KEY);
|
|
97
|
+
if (hit && Date.now() - hit.at < CATALOG_TTL_MS) {
|
|
98
|
+
return { usedMock: false, options: hit.options };
|
|
99
|
+
}
|
|
100
|
+
const actions = await new MeshyProvider({ env, slug }).listActions();
|
|
101
|
+
const options: MotionOption[] = actions
|
|
102
|
+
.filter((a) => typeof a.id === 'number' && a.id > 0)
|
|
103
|
+
.map((a) => ({
|
|
104
|
+
system: 'meshy',
|
|
105
|
+
id: a.id,
|
|
106
|
+
label: a.name,
|
|
107
|
+
category: a.category,
|
|
108
|
+
rigType: a.rigType,
|
|
109
|
+
isFree: a.isFree,
|
|
110
|
+
previewGifUrl: a.previewGifUrl,
|
|
111
|
+
}));
|
|
112
|
+
meshyCache.set(CATALOG_KEY, { at: Date.now(), options });
|
|
113
|
+
return { usedMock: false, options };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface MotionFilter {
|
|
117
|
+
query?: string;
|
|
118
|
+
category?: string;
|
|
119
|
+
rigType?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// In-memory filter shared by the UI browser and the AI tool, so both narrow the
|
|
123
|
+
// same catalog the same way (PLAN §8-Q1b).
|
|
124
|
+
export function filterMotions(options: readonly MotionOption[], f: MotionFilter): MotionOption[] {
|
|
125
|
+
const q = f.query?.trim().toLowerCase();
|
|
126
|
+
return options.filter(
|
|
127
|
+
(o) =>
|
|
128
|
+
(!q || o.label.toLowerCase().includes(q) || String(o.id).includes(q)) &&
|
|
129
|
+
(!f.category || o.category === f.category) &&
|
|
130
|
+
// rigType is degenerate today: the vendored Meshy catalog has no rig-type
|
|
131
|
+
// column (Meshy exposes no per-rig list endpoint), so every option's
|
|
132
|
+
// rigType is null. Match loosely — a requested rigType against an unknown
|
|
133
|
+
// (null) option is treated as a match rather than filtering everything
|
|
134
|
+
// out. PLAN §8-Q1.
|
|
135
|
+
(!f.rigType || o.rigType === null || o.rigType === f.rigType),
|
|
136
|
+
);
|
|
137
|
+
}
|