@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
package/server/env.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Env + feature-gate resolution for gen3d providers.
|
|
2
|
+
//
|
|
3
|
+
// Real provider calls are OFF by default. They turn on ONLY when both:
|
|
4
|
+
// 1. GEN3D_ENABLE_REAL_PROVIDERS === "1", and
|
|
5
|
+
// 2. a 3D gateway key is present in Studio global .env (FORGEAX_3D_GATEWAY_KEY,
|
|
6
|
+
// ANTHROPIC_API_KEY, or LITELLM_PROXY_KEY — see pickLitellmFromEnv).
|
|
7
|
+
// Otherwise generation falls back to the deterministic no-quota mock. Secrets
|
|
8
|
+
// are read from process.env (server loads $FORGEAX_PROJECT_ROOT/.env) or, for
|
|
9
|
+
// standalone smokes, from the plugin-local .env. Nothing here is logged.
|
|
10
|
+
//
|
|
11
|
+
// All 3D providers now route through the LiteLLM gateway — see AGENTS.md
|
|
12
|
+
// «最优 > 兼容». Hunyuan / Rodin / VISVISE never read per-vendor keys.
|
|
13
|
+
// Meshy production is gateway-only. A local recording override
|
|
14
|
+
// (GEN3D_MESHY_DIRECT=1 + MESHY_API_KEY) may talk to api.meshy.ai; it is not
|
|
15
|
+
// a production path and must not appear in the credentials UI.
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
+
import { dirname, resolve } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
let loaded = false;
|
|
22
|
+
|
|
23
|
+
// Minimal .env parser for the plugin-local file. Does not override values
|
|
24
|
+
// already present in process.env (the server's .env wins).
|
|
25
|
+
function loadPluginEnvOnce(): void {
|
|
26
|
+
if (loaded) return;
|
|
27
|
+
loaded = true;
|
|
28
|
+
// Stable across runtimes: import.meta.dir is bun-specific, but
|
|
29
|
+
// import.meta.url + fileURLToPath works in bun, node, and tsc typecheck.
|
|
30
|
+
const envPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '.env');
|
|
31
|
+
if (!existsSync(envPath)) return;
|
|
32
|
+
for (const rawLine of readFileSync(envPath, 'utf8').split('\n')) {
|
|
33
|
+
const line = rawLine.trim();
|
|
34
|
+
if (!line || line.startsWith('#')) continue;
|
|
35
|
+
const eq = line.indexOf('=');
|
|
36
|
+
if (eq === -1) continue;
|
|
37
|
+
const key = line.slice(0, eq).trim();
|
|
38
|
+
if (key in process.env) continue;
|
|
39
|
+
let value = line.slice(eq + 1).trim();
|
|
40
|
+
if (
|
|
41
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
42
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
43
|
+
) {
|
|
44
|
+
value = value.slice(1, -1);
|
|
45
|
+
}
|
|
46
|
+
process.env[key] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function read(name: string): string | undefined {
|
|
51
|
+
loadPluginEnvOnce();
|
|
52
|
+
const value = process.env[name];
|
|
53
|
+
return value && value.trim() ? value.trim() : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function realProvidersEnabled(): boolean {
|
|
57
|
+
return read('GEN3D_ENABLE_REAL_PROVIDERS') === '1';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface LitellmEnv {
|
|
61
|
+
apiKey: string;
|
|
62
|
+
baseUrl: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DEFAULT_LITELLM_BASE = 'https://llm-proxy.forgeax.com';
|
|
66
|
+
|
|
67
|
+
function normalizeProxyBase(raw: string): string {
|
|
68
|
+
return raw.replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 3D gateway base URL is resolved SEPARATELY from the chat/image
|
|
72
|
+
// LITELLM_PROXY_BASE_URL: that variable points at the chat proxy (e.g.
|
|
73
|
+
// Moonshot) which does NOT host /v1/3d/generations. Priority:
|
|
74
|
+
// 1. FORGEAX_3D_GATEWAY_BASE_URL — 3D-only override (shared by gen3d /
|
|
75
|
+
// ai-asset; ops pins a 3D-specific host)
|
|
76
|
+
// 2. ANTHROPIC_BASE_URL — the forgeax proxy (same host that serves 3D)
|
|
77
|
+
// 3. DEFAULT_LITELLM_BASE — https://llm-proxy.forgeax.com
|
|
78
|
+
// LITELLM_PROXY_BASE_URL is intentionally NOT consulted for 3D.
|
|
79
|
+
function resolveGatewayBaseUrl(env: Record<string, string | undefined>): string {
|
|
80
|
+
const dedicated = env.FORGEAX_3D_GATEWAY_BASE_URL?.trim();
|
|
81
|
+
if (dedicated) return normalizeProxyBase(dedicated);
|
|
82
|
+
const anthropic = env.ANTHROPIC_BASE_URL?.trim();
|
|
83
|
+
if (anthropic) return normalizeProxyBase(anthropic);
|
|
84
|
+
return DEFAULT_LITELLM_BASE;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolve LiteLLM gateway credentials for 3D generation. The 3D gateway is
|
|
89
|
+
* decoupled from the chat/image LITELLM_PROXY_* vars, which may point at a
|
|
90
|
+
* different proxy (e.g. Moonshot) that does not host /v1/3d/generations.
|
|
91
|
+
*
|
|
92
|
+
* Key priority:
|
|
93
|
+
* 1. FORGEAX_3D_GATEWAY_KEY — 3D-only override (shared by all 3D plugins)
|
|
94
|
+
* 2. ANTHROPIC_API_KEY — the forgeax proxy key (default 3D gateway auth)
|
|
95
|
+
* 3. LITELLM_PROXY_KEY — legacy fallback (kept for pre-decouple configs)
|
|
96
|
+
* Base URL priority: FORGEAX_3D_GATEWAY_BASE_URL > ANTHROPIC_BASE_URL > default.
|
|
97
|
+
* Plugin-local .env must NOT hold a separate gateway key — configure in
|
|
98
|
+
* Studio Settings → API Keys.
|
|
99
|
+
*/
|
|
100
|
+
export function pickLitellmFromEnv(env: Record<string, string | undefined>): LitellmEnv | null {
|
|
101
|
+
const dedicatedKey = env.FORGEAX_3D_GATEWAY_KEY?.trim();
|
|
102
|
+
if (dedicatedKey) {
|
|
103
|
+
return { apiKey: dedicatedKey, baseUrl: resolveGatewayBaseUrl(env) };
|
|
104
|
+
}
|
|
105
|
+
const anthropicKey = env.ANTHROPIC_API_KEY?.trim();
|
|
106
|
+
if (anthropicKey) {
|
|
107
|
+
return { apiKey: anthropicKey, baseUrl: resolveGatewayBaseUrl(env) };
|
|
108
|
+
}
|
|
109
|
+
const proxyKey = env.LITELLM_PROXY_KEY?.trim();
|
|
110
|
+
if (proxyKey) {
|
|
111
|
+
return { apiKey: proxyKey, baseUrl: resolveGatewayBaseUrl(env) };
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Returns the LiteLLM gateway credentials, or null when not configured.
|
|
117
|
+
// All 3D providers now route through this single gateway key.
|
|
118
|
+
export function getLitellmEnv(): LitellmEnv | null {
|
|
119
|
+
loadPluginEnvOnce();
|
|
120
|
+
return pickLitellmFromEnv(process.env);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface HunyuanEnv {
|
|
124
|
+
apiKey: string;
|
|
125
|
+
baseUrl: string;
|
|
126
|
+
defaultFaceCount: number;
|
|
127
|
+
pollIntervalMs: number;
|
|
128
|
+
pollTimeoutMs: number;
|
|
129
|
+
rateLimitPerMin: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Returns null when the real Hunyuan path is not fully configured. Now reads the
|
|
133
|
+
// LiteLLM gateway key instead of the direct HUNYUAN_API_KEY. Callers must fall
|
|
134
|
+
// back to mock when this is null — never throw a quota path on by accident.
|
|
135
|
+
export function getHunyuanEnv(): HunyuanEnv | null {
|
|
136
|
+
if (!realProvidersEnabled()) return null;
|
|
137
|
+
const litellm = getLitellmEnv();
|
|
138
|
+
if (!litellm) return null;
|
|
139
|
+
return {
|
|
140
|
+
apiKey: litellm.apiKey,
|
|
141
|
+
baseUrl: litellm.baseUrl,
|
|
142
|
+
defaultFaceCount: toInt(read('HUNYUAN_DEFAULT_FACE_COUNT'), 30000),
|
|
143
|
+
pollIntervalMs: toInt(read('HUNYUAN_POLL_INTERVAL_MS'), 5000),
|
|
144
|
+
pollTimeoutMs: toInt(read('HUNYUAN_POLL_TIMEOUT_MS'), 600000),
|
|
145
|
+
rateLimitPerMin: toInt(read('HUNYUAN_RATE_LIMIT_PER_MIN'), 3),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type MeshyTransport = 'gateway' | 'direct';
|
|
150
|
+
|
|
151
|
+
export interface MeshyEnv {
|
|
152
|
+
apiKey: string;
|
|
153
|
+
baseUrl: string;
|
|
154
|
+
/** Default gateway. `direct` is a local recording override (own Meshy key). */
|
|
155
|
+
transport: MeshyTransport;
|
|
156
|
+
defaultPolycount: number;
|
|
157
|
+
pollIntervalMs: number;
|
|
158
|
+
pollTimeoutMs: number;
|
|
159
|
+
rateLimitPerMin: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const DEFAULT_MESHY_DIRECT_BASE = 'https://api.meshy.ai';
|
|
163
|
+
|
|
164
|
+
function meshyTuning(env: Record<string, string | undefined>): Pick<
|
|
165
|
+
MeshyEnv,
|
|
166
|
+
'defaultPolycount' | 'pollIntervalMs' | 'pollTimeoutMs' | 'rateLimitPerMin'
|
|
167
|
+
> {
|
|
168
|
+
return {
|
|
169
|
+
defaultPolycount: toInt(env.MESHY_DEFAULT_POLYCOUNT, 30000),
|
|
170
|
+
pollIntervalMs: toInt(env.MESHY_POLL_INTERVAL_MS, 5000),
|
|
171
|
+
pollTimeoutMs: toInt(env.MESHY_POLL_TIMEOUT_MS, 600000),
|
|
172
|
+
rateLimitPerMin: toInt(env.MESHY_RATE_LIMIT_PER_MIN, 3),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Meshy credentials. Production stays on the LiteLLM gateway.
|
|
178
|
+
*
|
|
179
|
+
* Local recording override (no UI): `GEN3D_MESHY_DIRECT=1` + `MESHY_API_KEY`
|
|
180
|
+
* talks to api.meshy.ai with the operator's own credits. When the override
|
|
181
|
+
* switch is on but the key is missing, return null — do not silently spend
|
|
182
|
+
* the team gateway quota.
|
|
183
|
+
*/
|
|
184
|
+
export function pickMeshyFromEnv(env: Record<string, string | undefined>): MeshyEnv | null {
|
|
185
|
+
if (env.GEN3D_ENABLE_REAL_PROVIDERS !== '1') return null;
|
|
186
|
+
if (env.GEN3D_MESHY_DIRECT === '1') {
|
|
187
|
+
const key = env.MESHY_API_KEY?.trim();
|
|
188
|
+
if (!key) return null;
|
|
189
|
+
const rawBase = env.MESHY_BASE_URL?.trim() || DEFAULT_MESHY_DIRECT_BASE;
|
|
190
|
+
return {
|
|
191
|
+
apiKey: key,
|
|
192
|
+
baseUrl: rawBase.replace(/\/+$/, ''),
|
|
193
|
+
transport: 'direct',
|
|
194
|
+
...meshyTuning(env),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const litellm = pickLitellmFromEnv(env);
|
|
198
|
+
if (!litellm) return null;
|
|
199
|
+
return {
|
|
200
|
+
apiKey: litellm.apiKey,
|
|
201
|
+
baseUrl: litellm.baseUrl,
|
|
202
|
+
transport: 'gateway',
|
|
203
|
+
...meshyTuning(env),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function getMeshyEnv(): MeshyEnv | null {
|
|
208
|
+
loadPluginEnvOnce();
|
|
209
|
+
return pickMeshyFromEnv(process.env);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export interface RodinEnv {
|
|
213
|
+
apiKey: string;
|
|
214
|
+
baseUrl: string;
|
|
215
|
+
pollIntervalMs: number;
|
|
216
|
+
pollTimeoutMs: number;
|
|
217
|
+
rateLimitPerMin: number;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Rodin on the LiteLLM gateway (`rodin-3d`). Same key as Meshy/Hunyuan.
|
|
221
|
+
// Never reads RODIN_API_KEY (old Hyper3D). Callers must not import rodin.ts.
|
|
222
|
+
export function getRodinEnv(): RodinEnv | null {
|
|
223
|
+
if (!realProvidersEnabled()) return null;
|
|
224
|
+
const litellm = getLitellmEnv();
|
|
225
|
+
if (!litellm) return null;
|
|
226
|
+
return {
|
|
227
|
+
apiKey: litellm.apiKey,
|
|
228
|
+
baseUrl: litellm.baseUrl,
|
|
229
|
+
pollIntervalMs: toInt(read('RODIN_POLL_INTERVAL_MS'), 5000),
|
|
230
|
+
pollTimeoutMs: toInt(read('RODIN_POLL_TIMEOUT_MS'), 600000),
|
|
231
|
+
rateLimitPerMin: toInt(read('RODIN_RATE_LIMIT_PER_MIN'), 3),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface VisviseEnv {
|
|
236
|
+
apiKey: string;
|
|
237
|
+
baseUrl: string;
|
|
238
|
+
pollIntervalMs: number;
|
|
239
|
+
pollTimeoutMs: number;
|
|
240
|
+
rateLimitPerMin: number;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function getVisviseEnv(): VisviseEnv | null {
|
|
244
|
+
if (!realProvidersEnabled()) return null;
|
|
245
|
+
const litellm = getLitellmEnv();
|
|
246
|
+
if (!litellm) return null;
|
|
247
|
+
return {
|
|
248
|
+
apiKey: litellm.apiKey,
|
|
249
|
+
baseUrl: litellm.baseUrl,
|
|
250
|
+
pollIntervalMs: toInt(read('VISVISE_POLL_INTERVAL_MS'), 5000),
|
|
251
|
+
pollTimeoutMs: toInt(read('VISVISE_POLL_TIMEOUT_MS'), 600000),
|
|
252
|
+
rateLimitPerMin: toInt(read('VISVISE_RATE_LIMIT_PER_MIN'), 3),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function toInt(value: string | undefined, fallback: number): number {
|
|
257
|
+
const n = value ? Number.parseInt(value, 10) : NaN;
|
|
258
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export interface CosEnv {
|
|
262
|
+
secretId: string;
|
|
263
|
+
secretKey: string;
|
|
264
|
+
bucket: string;
|
|
265
|
+
region: string;
|
|
266
|
+
// Presigned URL lifetime in seconds (input images are transfer artifacts).
|
|
267
|
+
signExpiresSec: number;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Returns null when COS upload is not fully configured. Unlike provider envs,
|
|
271
|
+
// COS upload is a transport convenience and is NOT gated by
|
|
272
|
+
// GEN3D_ENABLE_REAL_PROVIDERS: a user can upload a local image to host it even
|
|
273
|
+
// in mock mode. Callers fall back to "URL only" when this is null.
|
|
274
|
+
export function getCosEnv(): CosEnv | null {
|
|
275
|
+
const secretId = read('COS_SECRET_ID');
|
|
276
|
+
const secretKey = read('COS_SECRET_KEY');
|
|
277
|
+
const bucket = read('COS_BUCKET');
|
|
278
|
+
const region = read('COS_REGION');
|
|
279
|
+
if (!secretId || !secretKey || !bucket || !region) return null;
|
|
280
|
+
return {
|
|
281
|
+
secretId,
|
|
282
|
+
secretKey,
|
|
283
|
+
bucket,
|
|
284
|
+
region,
|
|
285
|
+
signExpiresSec: toInt(read('COS_SIGN_EXPIRES_SEC'), 24 * 3600),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// exportPlayableCharacter — merge + cook + write delivery trio (PLAN §5.5–§5.7).
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
cookExternalAssetMeta,
|
|
7
|
+
type ExternalAssetMeta,
|
|
8
|
+
} from '@forgeax-extension/external-asset-meta';
|
|
9
|
+
import {
|
|
10
|
+
effectiveSlots,
|
|
11
|
+
gameProfileFromPreset,
|
|
12
|
+
type GameMotionProfile,
|
|
13
|
+
type MotionSlotDef,
|
|
14
|
+
type RootMotionStrategy,
|
|
15
|
+
} from '../shared/playable-profile';
|
|
16
|
+
import { motionRefKey, type Gen3DAssetManifest } from '../shared/manifest';
|
|
17
|
+
import type { PerGameAssetStore } from './per-game-store';
|
|
18
|
+
import { mergePlayableCharacter, resolveExportSlots } from './merge-playable-character';
|
|
19
|
+
import { playableDeliveryLocalUrl } from '../shared/playable-preview-url';
|
|
20
|
+
|
|
21
|
+
export interface PlayableClipDelivery {
|
|
22
|
+
guid: string;
|
|
23
|
+
sourceIndex: number;
|
|
24
|
+
loop: boolean;
|
|
25
|
+
speed: number;
|
|
26
|
+
rootMotion: RootMotionStrategy;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PlayableDeliveryJson {
|
|
30
|
+
schemaVersion: 1;
|
|
31
|
+
kind: 'playable-character-delivery';
|
|
32
|
+
sourceAssetPath: string;
|
|
33
|
+
modelPath: string;
|
|
34
|
+
profileId: string;
|
|
35
|
+
profileVersion: number;
|
|
36
|
+
sceneGuid: string;
|
|
37
|
+
clips: Record<string, PlayableClipDelivery>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ExportPlayableResult =
|
|
41
|
+
| {
|
|
42
|
+
ok: true;
|
|
43
|
+
firstExport: boolean;
|
|
44
|
+
modelPath: string;
|
|
45
|
+
metaPath: string;
|
|
46
|
+
playablePath: string;
|
|
47
|
+
localUrl: string;
|
|
48
|
+
clipCount: number;
|
|
49
|
+
reusedGuidCount: number;
|
|
50
|
+
message: string;
|
|
51
|
+
}
|
|
52
|
+
| {
|
|
53
|
+
ok: false;
|
|
54
|
+
code: string;
|
|
55
|
+
message: string;
|
|
56
|
+
missingSlots?: string[];
|
|
57
|
+
retryable: boolean;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function sha256Hex(data: Uint8Array): string {
|
|
61
|
+
return createHash('sha256').update(data).digest('hex');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function mappingFingerprint(
|
|
65
|
+
slots: readonly MotionSlotDef[],
|
|
66
|
+
mappings: readonly { slotId: string; motionRefKey: string | null }[],
|
|
67
|
+
profileId: string,
|
|
68
|
+
profileVersion: number,
|
|
69
|
+
): string {
|
|
70
|
+
const map = Object.fromEntries(mappings.map((m) => [m.slotId, m.motionRefKey]));
|
|
71
|
+
const body = {
|
|
72
|
+
profileId,
|
|
73
|
+
profileVersion,
|
|
74
|
+
slots: slots.map((s) => ({
|
|
75
|
+
id: s.slotId,
|
|
76
|
+
required: s.required,
|
|
77
|
+
rootMotion: s.rootMotion,
|
|
78
|
+
speed: s.speed,
|
|
79
|
+
playbackMode: s.playbackMode,
|
|
80
|
+
motion: map[s.slotId] ?? null,
|
|
81
|
+
})),
|
|
82
|
+
};
|
|
83
|
+
return sha256Hex(new TextEncoder().encode(JSON.stringify(body)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function exists(path: string): Promise<boolean> {
|
|
87
|
+
try {
|
|
88
|
+
await access(path);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function stemFromAssetPath(assetPath: string): string {
|
|
96
|
+
const base = assetPath.split('/').pop() ?? 'character.glb';
|
|
97
|
+
return base.replace(/\.glb$/i, '') || 'character';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function exportPlayableCharacter(
|
|
101
|
+
store: PerGameAssetStore,
|
|
102
|
+
args: { slug: string; assetPath: string; forceWizardConfirm?: boolean },
|
|
103
|
+
): Promise<ExportPlayableResult> {
|
|
104
|
+
const { slug, assetPath } = args;
|
|
105
|
+
const manifest = await store.getAsset(slug, assetPath);
|
|
106
|
+
if (!manifest) {
|
|
107
|
+
return { ok: false, code: 'asset_not_found', message: 'Character asset not found.', retryable: false };
|
|
108
|
+
}
|
|
109
|
+
if (manifest.assetSlot !== 'characters') {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
code: 'not_a_character',
|
|
113
|
+
message: 'Export Playable Character is only for characters (ROLE1).',
|
|
114
|
+
retryable: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (!manifest.readiness.rigged) {
|
|
118
|
+
return {
|
|
119
|
+
ok: false,
|
|
120
|
+
code: 'not_rigged',
|
|
121
|
+
message: 'Character is not rigged yet — finish auto-rig before export.',
|
|
122
|
+
retryable: false,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const gameProfileStored = await store.getGameMotionProfile(slug);
|
|
127
|
+
const gameProfile: GameMotionProfile =
|
|
128
|
+
gameProfileStored ?? gameProfileFromPreset('basic-character-v1', new Date(0).toISOString());
|
|
129
|
+
const state = await store.getCharacterPlayableState(slug, assetPath);
|
|
130
|
+
const override = state?.override ?? null;
|
|
131
|
+
const mapping = state?.mapping ?? null;
|
|
132
|
+
if (!mapping || !mapping.confirmed) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
code: 'mapping_not_confirmed',
|
|
136
|
+
message: 'Motion mapping is not confirmed yet — open the export wizard and confirm slots.',
|
|
137
|
+
retryable: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const slots = effectiveSlots(gameProfile, override);
|
|
142
|
+
const resolved = resolveExportSlots({
|
|
143
|
+
slots,
|
|
144
|
+
mappings: mapping.mappings,
|
|
145
|
+
});
|
|
146
|
+
if (!resolved.ok) {
|
|
147
|
+
return {
|
|
148
|
+
ok: false,
|
|
149
|
+
code: resolved.code,
|
|
150
|
+
message: resolved.message,
|
|
151
|
+
missingSlots: resolved.missingSlots,
|
|
152
|
+
retryable: false,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const rigged = await store.readAssetFile(slug, assetPath, 'rigged_model', 'glb');
|
|
157
|
+
if (!rigged) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
code: 'missing_rigged_glb',
|
|
161
|
+
message: 'No rigged_model.glb dependency found for this character.',
|
|
162
|
+
retryable: false,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Index animated_model files by motionRefKey.
|
|
167
|
+
const motionFiles = manifest.files.filter((f) => f.role === 'animated_model' && f.format === 'glb' && f.motionRef);
|
|
168
|
+
const byKey = new Map<string, (typeof motionFiles)[number]>();
|
|
169
|
+
for (const f of motionFiles) {
|
|
170
|
+
if (f.motionRef) byKey.set(motionRefKey(f.motionRef), f);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const mergeSlots: { slotId: string; motionGlbBytes: Uint8Array; rootMotion: RootMotionStrategy }[] = [];
|
|
174
|
+
for (const slotId of resolved.exportSlotIds) {
|
|
175
|
+
const key = resolved.mappingBySlot.get(slotId)!;
|
|
176
|
+
const file = byKey.get(key);
|
|
177
|
+
if (!file) {
|
|
178
|
+
return {
|
|
179
|
+
ok: false,
|
|
180
|
+
code: 'motion_file_missing',
|
|
181
|
+
message: `Mapped motion ${key} for slot ${slotId} is not on disk.`,
|
|
182
|
+
missingSlots: [slotId],
|
|
183
|
+
retryable: false,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const { data } = await readSpecificFile(store, slug, file.storageKey);
|
|
187
|
+
if (!data) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
code: 'motion_file_missing',
|
|
191
|
+
message: `Cannot read motion file for slot ${slotId} (${file.storageKey}).`,
|
|
192
|
+
missingSlots: [slotId],
|
|
193
|
+
retryable: true,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
const slotDef = slots.find((s) => s.slotId === slotId)!;
|
|
197
|
+
mergeSlots.push({ slotId, motionGlbBytes: data, rootMotion: slotDef.rootMotion });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const merged = await mergePlayableCharacter({
|
|
201
|
+
baseRiggedGlbBytes: rigged.data,
|
|
202
|
+
slots: mergeSlots,
|
|
203
|
+
});
|
|
204
|
+
if (!merged.ok) {
|
|
205
|
+
return { ok: false, code: merged.code, message: merged.message, retryable: true };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const stem = stemFromAssetPath(assetPath);
|
|
209
|
+
const modelRel = `assets/characters/${stem}-merged.glb`;
|
|
210
|
+
const metaRel = `${modelRel}.meta.json`;
|
|
211
|
+
const playableRel = `${modelRel}.playable.json`;
|
|
212
|
+
const glbAbs = store.resolveGameRelPath(slug, modelRel);
|
|
213
|
+
const metaAbs = store.resolveGameRelPath(slug, metaRel);
|
|
214
|
+
const playableAbs = store.resolveGameRelPath(slug, playableRel);
|
|
215
|
+
|
|
216
|
+
let existingMeta: ExternalAssetMeta | null = null;
|
|
217
|
+
let slotGuidRegistry: Record<string, string> = {};
|
|
218
|
+
const sidecarState = await readDeliverySnapshot(store, slug, assetPath);
|
|
219
|
+
if (sidecarState?.slotGuidRegistry) slotGuidRegistry = { ...sidecarState.slotGuidRegistry };
|
|
220
|
+
if (await exists(metaAbs)) {
|
|
221
|
+
try {
|
|
222
|
+
existingMeta = JSON.parse(await readFile(metaAbs, 'utf8')) as ExternalAssetMeta;
|
|
223
|
+
if (existingMeta.kind !== 'external-asset-package') existingMeta = null;
|
|
224
|
+
} catch {
|
|
225
|
+
existingMeta = null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const firstExport = !(await exists(glbAbs)) || !sidecarState;
|
|
229
|
+
|
|
230
|
+
const contentHash = `sha256:${sha256Hex(merged.bytes)}`;
|
|
231
|
+
const cooked = await cookExternalAssetMeta(merged.bytes, contentHash, `${stem}-merged.glb`, {
|
|
232
|
+
existingMeta,
|
|
233
|
+
animationSlotKeys: resolved.exportSlotIds,
|
|
234
|
+
slotGuidRegistry,
|
|
235
|
+
});
|
|
236
|
+
if (!cooked.ok) {
|
|
237
|
+
return { ok: false, code: cooked.code, message: cooked.message, retryable: true };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const scene = cooked.meta.subAssets.find((s) => s.kind === 'scene');
|
|
241
|
+
const clipsMeta = cooked.meta.subAssets.filter((s) => s.kind === 'animation-clip');
|
|
242
|
+
const clips: PlayableDeliveryJson['clips'] = {};
|
|
243
|
+
const nextRegistry: Record<string, string> = { ...slotGuidRegistry };
|
|
244
|
+
let reusedGuidCount = 0;
|
|
245
|
+
for (let i = 0; i < resolved.exportSlotIds.length; i++) {
|
|
246
|
+
const slotId = resolved.exportSlotIds[i]!;
|
|
247
|
+
const slotDef = slots.find((s) => s.slotId === slotId)!;
|
|
248
|
+
const clipSub = clipsMeta.find((c) => c.sourceIndex === i) ?? clipsMeta[i];
|
|
249
|
+
if (!clipSub) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
code: 'cook_clip_mismatch',
|
|
253
|
+
message: `Cooked meta missing animation-clip for slot ${slotId}`,
|
|
254
|
+
retryable: true,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (slotGuidRegistry[slotId] && slotGuidRegistry[slotId] === clipSub.guid) reusedGuidCount += 1;
|
|
258
|
+
nextRegistry[slotId] = clipSub.guid;
|
|
259
|
+
clips[slotId] = {
|
|
260
|
+
guid: clipSub.guid,
|
|
261
|
+
sourceIndex: clipSub.sourceIndex,
|
|
262
|
+
loop: slotDef.playbackMode === 'loop',
|
|
263
|
+
speed: slotDef.speed,
|
|
264
|
+
rootMotion: slotDef.rootMotion,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const playable: PlayableDeliveryJson = {
|
|
269
|
+
schemaVersion: 1,
|
|
270
|
+
kind: 'playable-character-delivery',
|
|
271
|
+
sourceAssetPath: assetPath,
|
|
272
|
+
modelPath: modelRel,
|
|
273
|
+
profileId: override?.basedOnProfileId ?? gameProfile.profileId,
|
|
274
|
+
profileVersion: override?.basedOnProfileVersion ?? gameProfile.profileVersion,
|
|
275
|
+
sceneGuid: scene?.guid ?? '',
|
|
276
|
+
clips,
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Atomic write: temp → validate → rename (PLAN §5.7).
|
|
280
|
+
await mkdir(dirname(glbAbs), { recursive: true });
|
|
281
|
+
const tmpGlb = `${glbAbs}.__export_tmp`;
|
|
282
|
+
const tmpMeta = `${metaAbs}.__export_tmp`;
|
|
283
|
+
const tmpPlayable = `${playableAbs}.__export_tmp`;
|
|
284
|
+
try {
|
|
285
|
+
await writeFile(tmpGlb, merged.bytes);
|
|
286
|
+
await writeFile(tmpMeta, `${JSON.stringify(cooked.meta, null, 2)}\n`, 'utf8');
|
|
287
|
+
await writeFile(tmpPlayable, `${JSON.stringify(playable, null, 2)}\n`, 'utf8');
|
|
288
|
+
// Basic validate temps exist + playable schema kind
|
|
289
|
+
const check = JSON.parse(await readFile(tmpPlayable, 'utf8')) as PlayableDeliveryJson;
|
|
290
|
+
if (check.kind !== 'playable-character-delivery') {
|
|
291
|
+
throw new Error('playable.json kind mismatch');
|
|
292
|
+
}
|
|
293
|
+
await rename(tmpGlb, glbAbs);
|
|
294
|
+
await rename(tmpMeta, metaAbs);
|
|
295
|
+
await rename(tmpPlayable, playableAbs);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
await rm(tmpGlb, { force: true }).catch(() => undefined);
|
|
298
|
+
await rm(tmpMeta, { force: true }).catch(() => undefined);
|
|
299
|
+
await rm(tmpPlayable, { force: true }).catch(() => undefined);
|
|
300
|
+
return {
|
|
301
|
+
ok: false,
|
|
302
|
+
code: 'write_failed',
|
|
303
|
+
message: err instanceof Error ? err.message : 'Failed to write playable delivery trio',
|
|
304
|
+
retryable: true,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const fp = mappingFingerprint(slots, mapping.mappings, playable.profileId, playable.profileVersion);
|
|
309
|
+
await store.updatePlayableDeliverySnapshot(slug, assetPath, {
|
|
310
|
+
modelPath: modelRel,
|
|
311
|
+
playablePath: playableRel,
|
|
312
|
+
profileId: playable.profileId,
|
|
313
|
+
profileVersion: playable.profileVersion,
|
|
314
|
+
clipSlotIds: resolved.exportSlotIds,
|
|
315
|
+
slotGuidRegistry: nextRegistry,
|
|
316
|
+
mappingFingerprint: fp,
|
|
317
|
+
exportedAt: new Date().toISOString(),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
ok: true,
|
|
322
|
+
firstExport,
|
|
323
|
+
modelPath: modelRel,
|
|
324
|
+
metaPath: metaRel,
|
|
325
|
+
playablePath: playableRel,
|
|
326
|
+
localUrl: playableDeliveryLocalUrl(slug, modelRel),
|
|
327
|
+
clipCount: resolved.exportSlotIds.length,
|
|
328
|
+
reusedGuidCount,
|
|
329
|
+
message:
|
|
330
|
+
'Playable character exported to Edit asset catalog. This does not auto-replace the game protagonist or modify gameplay code.',
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function readSpecificFile(
|
|
335
|
+
store: PerGameAssetStore,
|
|
336
|
+
slug: string,
|
|
337
|
+
storageKey: string,
|
|
338
|
+
): Promise<{ data: Uint8Array | null }> {
|
|
339
|
+
// storageKey like assets/3d/characters/hero.animated_model.motion-meshy-1.glb
|
|
340
|
+
const abs = store.resolveGameRelPath(slug, storageKey);
|
|
341
|
+
try {
|
|
342
|
+
const data = new Uint8Array(await readFile(abs));
|
|
343
|
+
return { data };
|
|
344
|
+
} catch {
|
|
345
|
+
return { data: null };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function readDeliverySnapshot(
|
|
350
|
+
store: PerGameAssetStore,
|
|
351
|
+
slug: string,
|
|
352
|
+
assetPath: string,
|
|
353
|
+
): Promise<NonNullable<Gen3DAssetManifest extends never ? never : import('../shared/manifest').AssetSidecar['custom']['playableDelivery']> | null> {
|
|
354
|
+
// Re-read via getAsset is insufficient (snapshot not on manifest). Use sidecar through resolveAssetFiles.
|
|
355
|
+
const resolved = store.resolveAssetFiles(slug, assetPath);
|
|
356
|
+
if (!resolved) return null;
|
|
357
|
+
try {
|
|
358
|
+
const raw = await readFile(resolved.sidecarAbs, 'utf8');
|
|
359
|
+
const sidecar = JSON.parse(raw) as { custom?: { playableDelivery?: { modelPath: string; playablePath: string; profileId: string; profileVersion: number; slotGuidRegistry: Record<string, string>; mappingFingerprint: string; exportedAt: string } } };
|
|
360
|
+
return sidecar.custom?.playableDelivery ?? null;
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|