@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,1016 @@
|
|
|
1
|
+
// PerGameAssetStore — dev-time AssetStorage backed by the local filesystem,
|
|
2
|
+
// per-game (ADR-0002 / 03-WORKSPACE-LAYOUT.md).
|
|
3
|
+
//
|
|
4
|
+
// Layout (active game's runtime asset library):
|
|
5
|
+
// <projectRoot>/.forgeax/games/<slug>/assets/3d/{characters|meshes}/<name>.glb
|
|
6
|
+
// <projectRoot>/.forgeax/games/<slug>/assets/3d/{characters|meshes}/<name>.glb.gen3d-meta.json
|
|
7
|
+
// <projectRoot>/.forgeax/games/<slug>/assets/3d/{characters|meshes}/<name>.png (preview)
|
|
8
|
+
// <projectRoot>/.forgeax/games/<slug>/assets/3d/{characters|meshes}/<name>.texture.png (external texture)
|
|
9
|
+
//
|
|
10
|
+
// Identity is the game-relative path of the main GLB. The on-disk sidecar uses
|
|
11
|
+
// the v2 workspace contract (schemaVersion/producer/dependencies[]/custom{}).
|
|
12
|
+
// gen3d-private fields live under `custom`. The runtime mechanism (kernel
|
|
13
|
+
// writeAsset/_index.json/path-slots) is known debt, not built here — we align
|
|
14
|
+
// the disk FORMAT only (ADR-0002 §"已知债务").
|
|
15
|
+
//
|
|
16
|
+
// OBJ source_mesh is dropped by default: only the GLB main mesh is kept.
|
|
17
|
+
|
|
18
|
+
import { createHash } from 'node:crypto';
|
|
19
|
+
import { lstatSync } from 'node:fs';
|
|
20
|
+
import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
21
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
ASSET_SLOT_DIRS,
|
|
25
|
+
MESHY_FREE_RUN_ID,
|
|
26
|
+
MESHY_FREE_WALK_ID,
|
|
27
|
+
computeReadiness,
|
|
28
|
+
emptyQuality,
|
|
29
|
+
motionRefFromLegacy,
|
|
30
|
+
reportToScore,
|
|
31
|
+
type AssetSidecar,
|
|
32
|
+
type AssetSlot,
|
|
33
|
+
type FileFormat,
|
|
34
|
+
type FileRole,
|
|
35
|
+
type Gen3DAssetManifest,
|
|
36
|
+
type ManifestFile,
|
|
37
|
+
type MotionRef,
|
|
38
|
+
type MotionType,
|
|
39
|
+
type PlayableDeliverySnapshot,
|
|
40
|
+
type QualityReport,
|
|
41
|
+
type SidecarDependency,
|
|
42
|
+
type SkeletonProfile,
|
|
43
|
+
} from '../shared/manifest';
|
|
44
|
+
import type {
|
|
45
|
+
AppendDerivedFilesInput,
|
|
46
|
+
AssetFileInput,
|
|
47
|
+
AssetStorage,
|
|
48
|
+
PutScratchInput,
|
|
49
|
+
PutScratchResult,
|
|
50
|
+
WriteAssetInput,
|
|
51
|
+
} from './asset-storage';
|
|
52
|
+
import type { CharacterMotionOverride, GameMotionProfile, MotionMappingDraft } from '../shared/playable-profile';
|
|
53
|
+
|
|
54
|
+
const PLUGIN_ID = 'gen3d';
|
|
55
|
+
const PLUGIN_VERSION = '0.1.0';
|
|
56
|
+
|
|
57
|
+
/** Gen3d provenance sidecar — NOT engine pack `*.meta.json` (pack scanner collision). */
|
|
58
|
+
export const GEN3D_SIDECAR_SUFFIX = '.glb.gen3d-meta.json';
|
|
59
|
+
|
|
60
|
+
function sidecarAbsForGlbFile(dir: string, glbFileName: string): string {
|
|
61
|
+
return resolve(dir, `${glbFileName}.gen3d-meta.json`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function legacySidecarAbsForGlbFile(dir: string, glbFileName: string): string {
|
|
65
|
+
return resolve(dir, `${glbFileName}.meta.json`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True gen3d private sidecar (producer/custom provenance). Clean engine
|
|
70
|
+
* `external-asset-package` meta must NEVER be treated as gen3d private
|
|
71
|
+
* (PLAN §5.4 — dual-sidecar coexistence).
|
|
72
|
+
*/
|
|
73
|
+
function isGen3dPrivateSidecar(parsed: unknown): boolean {
|
|
74
|
+
if (parsed === null || typeof parsed !== 'object') return false;
|
|
75
|
+
const obj = parsed as Record<string, unknown>;
|
|
76
|
+
if (obj.kind === 'external-asset-package') return false;
|
|
77
|
+
const producer = obj.producer;
|
|
78
|
+
if (
|
|
79
|
+
producer !== null &&
|
|
80
|
+
typeof producer === 'object' &&
|
|
81
|
+
(producer as { plugin?: unknown }).plugin === 'gen3d'
|
|
82
|
+
) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
const custom = obj.custom;
|
|
86
|
+
if (
|
|
87
|
+
custom !== null &&
|
|
88
|
+
typeof custom === 'object' &&
|
|
89
|
+
'assetSlot' in (custom as object) &&
|
|
90
|
+
'provider' in (custom as object)
|
|
91
|
+
) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the on-disk gen3d sidecar. Migrates legacy `*.glb.meta.json` →
|
|
99
|
+
* `*.glb.gen3d-meta.json` ONLY when the legacy file is confirmed gen3d private.
|
|
100
|
+
* Co-located clean engine `*.glb.meta.json` is left untouched.
|
|
101
|
+
*/
|
|
102
|
+
async function resolveExistingSidecarAbs(dir: string, glbFileName: string): Promise<string | null> {
|
|
103
|
+
const newAbs = sidecarAbsForGlbFile(dir, glbFileName);
|
|
104
|
+
const legacyAbs = legacySidecarAbsForGlbFile(dir, glbFileName);
|
|
105
|
+
|
|
106
|
+
let hasNew = false;
|
|
107
|
+
try {
|
|
108
|
+
await access(newAbs);
|
|
109
|
+
hasNew = true;
|
|
110
|
+
} catch (error) {
|
|
111
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let hasLegacy = false;
|
|
115
|
+
try {
|
|
116
|
+
await access(legacyAbs);
|
|
117
|
+
hasLegacy = true;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (hasNew) {
|
|
123
|
+
// Keep co-located engine meta (do not delete legacyAbs).
|
|
124
|
+
return newAbs;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (hasLegacy) {
|
|
128
|
+
const raw = await readFile(legacyAbs, 'utf8');
|
|
129
|
+
let parsed: unknown;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(raw);
|
|
132
|
+
} catch {
|
|
133
|
+
// Unreadable legacy file — leave it for humans; not a gen3d sidecar.
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
if (!isGen3dPrivateSidecar(parsed)) {
|
|
137
|
+
// Clean engine meta (or unknown) — not ours to migrate/delete.
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
await writeFile(newAbs, raw.endsWith('\n') ? raw : `${raw}\n`, 'utf8');
|
|
141
|
+
await rm(legacyAbs, { force: true });
|
|
142
|
+
return newAbs;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function writeSidecarJson(
|
|
149
|
+
dir: string,
|
|
150
|
+
glbFileName: string,
|
|
151
|
+
sidecar: AssetSidecar,
|
|
152
|
+
): Promise<void> {
|
|
153
|
+
const newAbs = sidecarAbsForGlbFile(dir, glbFileName);
|
|
154
|
+
await writeFile(newAbs, `${JSON.stringify(sidecar, null, 2)}\n`, 'utf8');
|
|
155
|
+
// Do NOT delete co-located engine `*.glb.meta.json` (PLAN §5.4).
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function projectRoot(): string {
|
|
159
|
+
// Match the marketplace convention (see node-editor runtime.ts).
|
|
160
|
+
return process.env.FORGEAX_PROJECT_ROOT ?? resolve(process.cwd(), '.forgeax-runtime');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Reject path segments that would escape their parent. Mirrors the server's
|
|
164
|
+
// PathManager.safeSegment so slug handling is consistent on both sides.
|
|
165
|
+
function safeSlug(slug: string): string {
|
|
166
|
+
if (!slug || slug.includes('/') || slug.includes('\\') || slug === '..' || slug.includes('\0')) {
|
|
167
|
+
throw Object.assign(new Error(`unsafe slug ${JSON.stringify(slug)}`), { code: 'invalid_slug' });
|
|
168
|
+
}
|
|
169
|
+
return slug;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function gameRoot(slug: string): string {
|
|
173
|
+
return resolve(projectRoot(), '.forgeax', 'games', safeSlug(slug));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function slotDir(slug: string, slot: AssetSlot): string {
|
|
177
|
+
return resolve(gameRoot(slug), 'assets', '3d', ASSET_SLOT_DIRS[slot]);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Game-relative path of a file in a slot (forward slashes; this is the asset id).
|
|
181
|
+
function relPath(slot: AssetSlot, fileName: string): string {
|
|
182
|
+
return `assets/3d/${ASSET_SLOT_DIRS[slot]}/${fileName}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sha256Hex(data: Uint8Array): string {
|
|
186
|
+
return createHash('sha256').update(data).digest('hex');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Sanitize a user/AI base name into a safe lowercase file stem. Falls back to a
|
|
190
|
+
// generic stem when the input has no usable characters.
|
|
191
|
+
function sanitizeBaseName(raw: string): string {
|
|
192
|
+
const cleaned = raw
|
|
193
|
+
.trim()
|
|
194
|
+
.toLowerCase()
|
|
195
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
196
|
+
.replace(/^-+|-+$/g, '')
|
|
197
|
+
.slice(0, 48);
|
|
198
|
+
return cleaned || 'asset';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const LOCAL_URL_PREFIX = '/api/game-assets';
|
|
202
|
+
const SCRATCH_URL_PREFIX = '/api/gen3d-scratch';
|
|
203
|
+
|
|
204
|
+
// Process-local per-asset async lock. appendDerivedFiles / clearRigAndMotions /
|
|
205
|
+
// deleteAsset do a read-modify-write on one asset's sidecar; concurrent appends
|
|
206
|
+
// to the same character (e.g. several motions at once) would otherwise drop
|
|
207
|
+
// entries / leave orphan files (ADR-0003). Keyed by `${slug}:${assetPath}`;
|
|
208
|
+
// unrelated assets stay parallel.
|
|
209
|
+
const assetLocks = new Map<string, Promise<unknown>>();
|
|
210
|
+
|
|
211
|
+
async function withAssetLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
212
|
+
const prev = assetLocks.get(key) ?? Promise.resolve();
|
|
213
|
+
// Serialize: run after the previous holder settles (ignore its outcome).
|
|
214
|
+
const run = prev.then(fn, fn);
|
|
215
|
+
// Track this run as the tail so the next caller chains after it. Swallow
|
|
216
|
+
// rejection on the tracked promise so one failure can't reject the chain.
|
|
217
|
+
assetLocks.set(
|
|
218
|
+
key,
|
|
219
|
+
run.then(
|
|
220
|
+
() => undefined,
|
|
221
|
+
() => undefined,
|
|
222
|
+
),
|
|
223
|
+
);
|
|
224
|
+
return run;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function localUrlFor(slug: string, rel: string): string {
|
|
228
|
+
// rel is "assets/3d/<slot>/<file>"; the route mounts at .../assets/3d/.
|
|
229
|
+
const tail = rel.replace(/^assets\/3d\//, '');
|
|
230
|
+
return `${LOCAL_URL_PREFIX}/${encodeURIComponent(slug)}/3d/${tail}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function scratchUrlFor(slug: string, sha256: string, format: FileFormat): string {
|
|
234
|
+
return `${SCRATCH_URL_PREFIX}/${encodeURIComponent(slug)}/${sha256}.${format}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// One generation may return several files. Keep exactly one main GLB
|
|
238
|
+
// (source_mesh) as identity; OBJ source_mesh is dropped. The remaining files
|
|
239
|
+
// become same-basename sidefiles: preview_image → <name>.<fmt> (png/jpg/webp),
|
|
240
|
+
// texture → <name>.texture.png, any other → <name>.<role>.<ext>.
|
|
241
|
+
interface PlannedFile {
|
|
242
|
+
input: AssetFileInput;
|
|
243
|
+
fileName: string;
|
|
244
|
+
isMain: boolean;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function planFiles(baseName: string, files: readonly AssetFileInput[]): PlannedFile[] {
|
|
248
|
+
const main = files.find((f) => f.role === 'source_mesh' && f.format === 'glb');
|
|
249
|
+
if (!main) {
|
|
250
|
+
throw Object.assign(new Error('no GLB source_mesh in generation result'), {
|
|
251
|
+
code: 'no_main_mesh',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
const planned: PlannedFile[] = [{ input: main, fileName: `${baseName}.glb`, isMain: true }];
|
|
255
|
+
for (const f of files) {
|
|
256
|
+
if (f === main) continue;
|
|
257
|
+
// Drop OBJ/MTL source_mesh sidefiles: GLB only (ADR-0002).
|
|
258
|
+
if (f.role === 'source_mesh') continue;
|
|
259
|
+
let fileName: string;
|
|
260
|
+
if (f.role === 'preview_image') fileName = `${baseName}.${f.format}`;
|
|
261
|
+
else if (f.role === 'texture') fileName = `${baseName}.texture.${f.format}`;
|
|
262
|
+
else fileName = `${baseName}.${f.role}.${f.format}`;
|
|
263
|
+
planned.push({ input: f, fileName, isMain: false });
|
|
264
|
+
}
|
|
265
|
+
return planned;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export class PerGameAssetStore implements AssetStorage {
|
|
269
|
+
// Pick a non-colliding base name. Caller passes the desired name; on a name
|
|
270
|
+
// collision (a different request produced the same name) we suffix -2, -3, …
|
|
271
|
+
// rather than overwrite. Cache hits never reach here (the orchestrator returns
|
|
272
|
+
// the existing asset before writing).
|
|
273
|
+
private async resolveFreeName(slug: string, slot: AssetSlot, desired: string): Promise<string> {
|
|
274
|
+
const base = sanitizeBaseName(desired);
|
|
275
|
+
const dir = slotDir(slug, slot);
|
|
276
|
+
let existing: Set<string>;
|
|
277
|
+
try {
|
|
278
|
+
existing = new Set(await readdir(dir));
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return base;
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
if (!existing.has(`${base}.glb`)) return base;
|
|
284
|
+
for (let i = 2; i < 1000; i += 1) {
|
|
285
|
+
if (!existing.has(`${base}-${i}.glb`)) return `${base}-${i}`;
|
|
286
|
+
}
|
|
287
|
+
return `${base}-${Date.now()}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async writeAsset(input: WriteAssetInput): Promise<Gen3DAssetManifest> {
|
|
291
|
+
const { slug, assetSlot, meta } = input;
|
|
292
|
+
const baseName = await this.resolveFreeName(slug, assetSlot, input.assetName);
|
|
293
|
+
const dir = slotDir(slug, assetSlot);
|
|
294
|
+
await mkdir(dir, { recursive: true });
|
|
295
|
+
|
|
296
|
+
const planned = planFiles(baseName, input.files);
|
|
297
|
+
const now = new Date().toISOString();
|
|
298
|
+
const manifestFiles: ManifestFile[] = [];
|
|
299
|
+
const dependencies: SidecarDependency[] = [];
|
|
300
|
+
let mainRel = '';
|
|
301
|
+
let mainSha = '';
|
|
302
|
+
let mainBytes = 0;
|
|
303
|
+
|
|
304
|
+
for (const p of planned) {
|
|
305
|
+
const abs = resolve(dir, p.fileName);
|
|
306
|
+
await writeFile(abs, p.input.data);
|
|
307
|
+
const sha256 = sha256Hex(p.input.data);
|
|
308
|
+
const rel = relPath(assetSlot, p.fileName);
|
|
309
|
+
const bytes = p.input.data.byteLength;
|
|
310
|
+
const isRiggedFbx = p.input.role === 'rigged_model' && p.input.format === 'fbx';
|
|
311
|
+
manifestFiles.push({
|
|
312
|
+
fileId: rel,
|
|
313
|
+
role: p.input.role,
|
|
314
|
+
format: p.input.format,
|
|
315
|
+
storageKey: rel,
|
|
316
|
+
bytes,
|
|
317
|
+
sha256,
|
|
318
|
+
localUrl: localUrlFor(slug, rel),
|
|
319
|
+
// Generation never produces a verified skeleton; only a verified rigging
|
|
320
|
+
// step in wb-3d-pipeline may set these (never inferred here).
|
|
321
|
+
hasSkeleton: false,
|
|
322
|
+
skeletonProfile: isRiggedFbx ? 'unknown' : 'unknown',
|
|
323
|
+
animationInputReady: false,
|
|
324
|
+
});
|
|
325
|
+
if (p.isMain) {
|
|
326
|
+
mainRel = rel;
|
|
327
|
+
mainSha = sha256;
|
|
328
|
+
mainBytes = bytes;
|
|
329
|
+
} else {
|
|
330
|
+
dependencies.push({ path: p.fileName, hash: `sha256:${sha256}`, kind: p.input.role });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const readiness = computeReadiness(manifestFiles);
|
|
335
|
+
const sidecar: AssetSidecar = {
|
|
336
|
+
schemaVersion: 1,
|
|
337
|
+
producer: { plugin: PLUGIN_ID, pluginVersion: PLUGIN_VERSION },
|
|
338
|
+
createdAt: now,
|
|
339
|
+
contentHash: `sha256:${mainSha}`,
|
|
340
|
+
size: mainBytes,
|
|
341
|
+
type: assetSlot === 'characters' ? 'gen3d-character' : 'gen3d-mesh',
|
|
342
|
+
dependencies,
|
|
343
|
+
custom: {
|
|
344
|
+
provider: meta.provider,
|
|
345
|
+
providerMode: meta.providerMode,
|
|
346
|
+
mode: meta.mode,
|
|
347
|
+
assetSlot,
|
|
348
|
+
sourceJobId: meta.sourceJobId,
|
|
349
|
+
prompt: meta.prompt,
|
|
350
|
+
sourceInputAssetPaths: meta.sourceInputAssetPaths,
|
|
351
|
+
...(meta.faceCount !== undefined ? { faceCount: meta.faceCount } : {}),
|
|
352
|
+
readiness,
|
|
353
|
+
...(meta.cacheKey ? { cacheKey: meta.cacheKey } : {}),
|
|
354
|
+
...(meta.meshyTaskRefs ? { meshyTaskRefs: meta.meshyTaskRefs } : {}),
|
|
355
|
+
...(meta.visviseRefs ? { visviseRefs: meta.visviseRefs } : {}),
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
await writeSidecarJson(dir, `${baseName}.glb`, sidecar);
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
manifestVersion: 1,
|
|
362
|
+
assetPath: mainRel,
|
|
363
|
+
assetSlot,
|
|
364
|
+
kind: 'mesh',
|
|
365
|
+
provider: meta.provider,
|
|
366
|
+
providerMode: meta.providerMode,
|
|
367
|
+
mode: meta.mode,
|
|
368
|
+
sourceJobId: meta.sourceJobId,
|
|
369
|
+
sourceInputAssetPaths: meta.sourceInputAssetPaths,
|
|
370
|
+
prompt: meta.prompt,
|
|
371
|
+
files: manifestFiles,
|
|
372
|
+
readiness,
|
|
373
|
+
quality: emptyQuality(),
|
|
374
|
+
targetFaceCount: meta.faceCount ?? null,
|
|
375
|
+
createdAt: now,
|
|
376
|
+
updatedAt: now,
|
|
377
|
+
...(meta.meshyTaskRefs ? { meshyTaskRefs: meta.meshyTaskRefs } : {}),
|
|
378
|
+
...(meta.visviseRefs ? { visviseRefs: meta.visviseRefs } : {}),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async getAsset(slug: string, assetPath: string): Promise<Gen3DAssetManifest | null> {
|
|
383
|
+
const { slot, fileName } = parseAssetPath(assetPath);
|
|
384
|
+
if (!slot) return null;
|
|
385
|
+
const dir = slotDir(slug, slot);
|
|
386
|
+
const sidecarAbs = await resolveExistingSidecarAbs(dir, fileName);
|
|
387
|
+
if (!sidecarAbs) return null;
|
|
388
|
+
let raw: string;
|
|
389
|
+
try {
|
|
390
|
+
raw = await readFile(sidecarAbs, 'utf8');
|
|
391
|
+
} catch (error) {
|
|
392
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
const sidecar = JSON.parse(raw) as AssetSidecar;
|
|
396
|
+
return sidecarToManifest(slug, slot, fileName, sidecar);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async listAssets(slug: string, assetSlot?: AssetSlot): Promise<Gen3DAssetManifest[]> {
|
|
400
|
+
const slots: AssetSlot[] = assetSlot ? [assetSlot] : ['characters', 'meshes'];
|
|
401
|
+
const out: Gen3DAssetManifest[] = [];
|
|
402
|
+
for (const slot of slots) {
|
|
403
|
+
const dir = slotDir(slug, slot);
|
|
404
|
+
let entries: string[];
|
|
405
|
+
try {
|
|
406
|
+
entries = await readdir(dir);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
const seen = new Set<string>();
|
|
412
|
+
for (const name of entries) {
|
|
413
|
+
let fileName: string | null = null;
|
|
414
|
+
if (name.endsWith('.glb.gen3d-meta.json')) {
|
|
415
|
+
fileName = name.replace(/\.gen3d-meta\.json$/, '');
|
|
416
|
+
} else if (name.endsWith('.glb.meta.json')) {
|
|
417
|
+
// One-time migration probe for true legacy gen3d sidecars only.
|
|
418
|
+
// Clean engine meta → resolve returns null → skip (not an asset candidate).
|
|
419
|
+
const candidate = name.replace(/\.meta\.json$/, '');
|
|
420
|
+
const migrated = await resolveExistingSidecarAbs(dir, candidate);
|
|
421
|
+
if (!migrated) continue;
|
|
422
|
+
fileName = candidate;
|
|
423
|
+
} else {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (seen.has(fileName)) continue;
|
|
427
|
+
seen.add(fileName);
|
|
428
|
+
const manifest = await this.getAsset(slug, relPath(slot, fileName));
|
|
429
|
+
if (manifest) out.push(manifest);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return out.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async deleteAsset(slug: string, assetPath: string): Promise<{ cacheKey: string | null }> {
|
|
436
|
+
return withAssetLock(`${slug}:${assetPath}`, async () => {
|
|
437
|
+
const { slot, fileName } = parseAssetPath(assetPath);
|
|
438
|
+
if (!slot) return { cacheKey: null };
|
|
439
|
+
const dir = slotDir(slug, slot);
|
|
440
|
+
const sidecarAbs = await resolveExistingSidecarAbs(dir, fileName);
|
|
441
|
+
|
|
442
|
+
let cacheKey: string | null = null;
|
|
443
|
+
let deps: SidecarDependency[] = [];
|
|
444
|
+
if (sidecarAbs) {
|
|
445
|
+
try {
|
|
446
|
+
const sidecar = JSON.parse(await readFile(sidecarAbs, 'utf8')) as AssetSidecar;
|
|
447
|
+
cacheKey = sidecar.custom?.cacheKey ?? null;
|
|
448
|
+
deps = sidecar.dependencies ?? [];
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Remove main GLB + sidecar(s) + every same-basename sidefile.
|
|
455
|
+
await rm(resolve(dir, fileName), { force: true });
|
|
456
|
+
await rm(sidecarAbsForGlbFile(dir, fileName), { force: true });
|
|
457
|
+
await rm(legacySidecarAbsForGlbFile(dir, fileName), { force: true });
|
|
458
|
+
for (const dep of deps) {
|
|
459
|
+
await rm(resolve(dir, dep.path), { force: true });
|
|
460
|
+
}
|
|
461
|
+
return { cacheKey };
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ─── Clear rig + motions (force re-rig) ────────────────────────────────────
|
|
466
|
+
async clearRigAndMotions(slug: string, assetPath: string): Promise<Gen3DAssetManifest> {
|
|
467
|
+
return withAssetLock(`${slug}:${assetPath}`, async () => {
|
|
468
|
+
const { slot, fileName } = parseAssetPath(assetPath);
|
|
469
|
+
if (!slot) {
|
|
470
|
+
throw Object.assign(new Error(`unrecognized assetPath ${JSON.stringify(assetPath)}`), {
|
|
471
|
+
code: 'invalid_asset_path',
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
const dir = slotDir(slug, slot);
|
|
475
|
+
const sidecarAbs = await resolveExistingSidecarAbs(dir, fileName);
|
|
476
|
+
if (!sidecarAbs) {
|
|
477
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
478
|
+
}
|
|
479
|
+
let sidecar: AssetSidecar;
|
|
480
|
+
try {
|
|
481
|
+
sidecar = JSON.parse(await readFile(sidecarAbs, 'utf8')) as AssetSidecar;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
484
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
485
|
+
}
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const kept: SidecarDependency[] = [];
|
|
490
|
+
for (const dep of sidecar.dependencies ?? []) {
|
|
491
|
+
if (dep.kind === 'rigged_model' || dep.kind === 'animated_model') {
|
|
492
|
+
await rm(resolve(dir, dep.path), { force: true });
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
kept.push(dep);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const { rig: _dropRig, ...customRest } = sidecar.custom ?? {};
|
|
499
|
+
void _dropRig;
|
|
500
|
+
const updated: AssetSidecar = {
|
|
501
|
+
...sidecar,
|
|
502
|
+
dependencies: kept,
|
|
503
|
+
custom: { ...customRest },
|
|
504
|
+
};
|
|
505
|
+
const manifest = sidecarToManifest(slug, slot, fileName, updated);
|
|
506
|
+
updated.custom = { ...updated.custom, readiness: manifest.readiness };
|
|
507
|
+
await writeSidecarJson(dir, fileName, updated);
|
|
508
|
+
return { ...manifest, updatedAt: new Date().toISOString() };
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ─── Append derived files (rig / motion) to an existing mesh asset ─────────
|
|
513
|
+
async appendDerivedFiles(input: AppendDerivedFilesInput): Promise<Gen3DAssetManifest> {
|
|
514
|
+
const { slug, assetPath } = input;
|
|
515
|
+
return withAssetLock(`${slug}:${assetPath}`, async () => {
|
|
516
|
+
const { slot, fileName } = parseAssetPath(assetPath);
|
|
517
|
+
if (!slot) {
|
|
518
|
+
throw Object.assign(new Error(`unrecognized assetPath ${JSON.stringify(assetPath)}`), {
|
|
519
|
+
code: 'invalid_asset_path',
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
const dir = slotDir(slug, slot);
|
|
523
|
+
const sidecarAbs = await resolveExistingSidecarAbs(dir, fileName);
|
|
524
|
+
if (!sidecarAbs) {
|
|
525
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
526
|
+
}
|
|
527
|
+
let sidecar: AssetSidecar;
|
|
528
|
+
try {
|
|
529
|
+
sidecar = JSON.parse(await readFile(sidecarAbs, 'utf8')) as AssetSidecar;
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
532
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
533
|
+
}
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const baseName = fileName.replace(/\.glb$/, '');
|
|
538
|
+
const deps = [...(sidecar.dependencies ?? [])];
|
|
539
|
+
|
|
540
|
+
for (const f of input.files) {
|
|
541
|
+
// <base>.<role>[.motion-<system>-<id>].<format> as a same-basename
|
|
542
|
+
// sidefile. The motion variant is per-file so one append can land the
|
|
543
|
+
// rigged model AND its bundled free clips together (ADR-0006 §8-Q6).
|
|
544
|
+
const variant = f.motionRef ? `.motion-${motionVariantSlug(f.motionRef)}` : '';
|
|
545
|
+
const depFileName = `${baseName}.${f.role}${variant}.${f.format}`;
|
|
546
|
+
const abs = resolve(dir, depFileName);
|
|
547
|
+
await writeFile(abs, f.data);
|
|
548
|
+
const sha256 = sha256Hex(f.data);
|
|
549
|
+
// Replace any existing dep with the same on-disk path (idempotent re-run).
|
|
550
|
+
const idx = deps.findIndex((d) => d.path === depFileName);
|
|
551
|
+
const dep: SidecarDependency = {
|
|
552
|
+
path: depFileName,
|
|
553
|
+
hash: `sha256:${sha256}`,
|
|
554
|
+
kind: f.role,
|
|
555
|
+
...(f.role === 'rigged_model' && input.skeleton
|
|
556
|
+
? {
|
|
557
|
+
hasSkeleton: input.skeleton.hasSkeleton,
|
|
558
|
+
skeletonProfile: input.skeleton.skeletonProfile,
|
|
559
|
+
animationInputReady: input.skeleton.animationInputReady,
|
|
560
|
+
}
|
|
561
|
+
: {}),
|
|
562
|
+
...(f.role === 'animated_model' && f.motionRef
|
|
563
|
+
? {
|
|
564
|
+
motionRef: f.motionRef,
|
|
565
|
+
// Legacy mirror so pre-motionRef readers still see hunyuan_v1.
|
|
566
|
+
...(f.motionRef.system === 'hunyuan_v1' ? { motionType: f.motionRef.id } : {}),
|
|
567
|
+
}
|
|
568
|
+
: {}),
|
|
569
|
+
};
|
|
570
|
+
if (idx >= 0) deps[idx] = dep;
|
|
571
|
+
else deps.push(dep);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const now = new Date().toISOString();
|
|
575
|
+
const updated: AssetSidecar = {
|
|
576
|
+
...sidecar,
|
|
577
|
+
dependencies: deps,
|
|
578
|
+
custom: {
|
|
579
|
+
...sidecar.custom,
|
|
580
|
+
// Persist rig-chain identity so apply-motion can dispatch by system
|
|
581
|
+
// and read the Meshy rig_task_id (ADR-0006 §Decision 3).
|
|
582
|
+
...(input.rigChain ? { rig: input.rigChain } : {}),
|
|
583
|
+
...(input.visviseRefs ? { visviseRefs: input.visviseRefs } : {}),
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
// Recompute readiness from the full file set (main + deps).
|
|
587
|
+
const manifest = sidecarToManifest(slug, slot, fileName, updated);
|
|
588
|
+
updated.custom = { ...updated.custom, readiness: manifest.readiness };
|
|
589
|
+
await writeSidecarJson(dir, fileName, updated);
|
|
590
|
+
return { ...manifest, updatedAt: now };
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ─── User label (display name) ────────────────────────────────────────────
|
|
595
|
+
async updateAssetLabel(
|
|
596
|
+
slug: string,
|
|
597
|
+
assetPath: string,
|
|
598
|
+
label: string | null,
|
|
599
|
+
): Promise<Gen3DAssetManifest> {
|
|
600
|
+
return withAssetLock(`${slug}:${assetPath}`, async () => {
|
|
601
|
+
const { slot, fileName } = parseAssetPath(assetPath);
|
|
602
|
+
if (!slot) {
|
|
603
|
+
throw Object.assign(new Error(`unrecognized assetPath ${JSON.stringify(assetPath)}`), {
|
|
604
|
+
code: 'invalid_asset_path',
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
const dir = slotDir(slug, slot);
|
|
608
|
+
const sidecarAbs = await resolveExistingSidecarAbs(dir, fileName);
|
|
609
|
+
if (!sidecarAbs) {
|
|
610
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
611
|
+
}
|
|
612
|
+
let sidecar: AssetSidecar;
|
|
613
|
+
try {
|
|
614
|
+
sidecar = JSON.parse(await readFile(sidecarAbs, 'utf8')) as AssetSidecar;
|
|
615
|
+
} catch (error) {
|
|
616
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
617
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
618
|
+
}
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
const trimmed = label?.trim() || null;
|
|
622
|
+
const updated: AssetSidecar = {
|
|
623
|
+
...sidecar,
|
|
624
|
+
custom: { ...sidecar.custom, userLabel: trimmed },
|
|
625
|
+
};
|
|
626
|
+
await writeSidecarJson(dir, fileName, updated);
|
|
627
|
+
return sidecarToManifest(slug, slot, fileName, updated);
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ─── Lazy quality persistence (ADR-0004) ──────────────────────────────────
|
|
632
|
+
async updateAssetQuality(
|
|
633
|
+
slug: string,
|
|
634
|
+
assetPath: string,
|
|
635
|
+
report: QualityReport,
|
|
636
|
+
): Promise<Gen3DAssetManifest> {
|
|
637
|
+
return withAssetLock(`${slug}:${assetPath}`, async () => {
|
|
638
|
+
const { slot, fileName } = parseAssetPath(assetPath);
|
|
639
|
+
if (!slot) {
|
|
640
|
+
throw Object.assign(new Error(`unrecognized assetPath ${JSON.stringify(assetPath)}`), {
|
|
641
|
+
code: 'invalid_asset_path',
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
const dir = slotDir(slug, slot);
|
|
645
|
+
const sidecarAbs = await resolveExistingSidecarAbs(dir, fileName);
|
|
646
|
+
if (!sidecarAbs) {
|
|
647
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
648
|
+
}
|
|
649
|
+
let sidecar: AssetSidecar;
|
|
650
|
+
try {
|
|
651
|
+
sidecar = JSON.parse(await readFile(sidecarAbs, 'utf8')) as AssetSidecar;
|
|
652
|
+
} catch (error) {
|
|
653
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
654
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
655
|
+
}
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
658
|
+
const updated: AssetSidecar = {
|
|
659
|
+
...sidecar,
|
|
660
|
+
custom: { ...sidecar.custom, quality: report },
|
|
661
|
+
};
|
|
662
|
+
await writeSidecarJson(dir, fileName, updated);
|
|
663
|
+
return sidecarToManifest(slug, slot, fileName, updated);
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ─── Game-level playable-character motion profile (R7, PLAN §4.1) ─────────
|
|
668
|
+
// NOT per-asset: one file per game, sibling to the per-game .gen3d/tmp/ scratch
|
|
669
|
+
// tree. Character override + motion mapping live in the character's OWN
|
|
670
|
+
// sidecar instead (see below) — never here (SSOT: this file only answers
|
|
671
|
+
// "what does this game default to").
|
|
672
|
+
gameMotionProfilePath(slug: string): string {
|
|
673
|
+
return resolve(gameRoot(slug), '.gen3d', 'playable-character-profile.json');
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async getGameMotionProfile(slug: string): Promise<GameMotionProfile | null> {
|
|
677
|
+
try {
|
|
678
|
+
const raw = await readFile(this.gameMotionProfilePath(slug), 'utf8');
|
|
679
|
+
return JSON.parse(raw) as GameMotionProfile;
|
|
680
|
+
} catch (error) {
|
|
681
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
682
|
+
throw error;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Atomic read-compute-write: updateFn sees the CURRENT stored profile (or null
|
|
687
|
+
// if none saved yet) and returns the next one. Read + write both happen inside
|
|
688
|
+
// the same lock so two concurrent saveAsGameDefault calls can't both read the
|
|
689
|
+
// same profileVersion and silently clobber each other (lost-update).
|
|
690
|
+
async setGameMotionProfile(
|
|
691
|
+
slug: string,
|
|
692
|
+
updateFn: (existing: GameMotionProfile | null) => GameMotionProfile,
|
|
693
|
+
): Promise<GameMotionProfile> {
|
|
694
|
+
return withAssetLock(`profile:${slug}`, async () => {
|
|
695
|
+
const existing = await this.getGameMotionProfile(slug);
|
|
696
|
+
const next = updateFn(existing);
|
|
697
|
+
const abs = this.gameMotionProfilePath(slug);
|
|
698
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
699
|
+
await writeFile(abs, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
700
|
+
return next;
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// ─── Character playable override + motion mapping (R7) ────────────────────
|
|
705
|
+
// Both live in the source character's own gen3d sidecar under custom
|
|
706
|
+
// (PLAN §4.1 storage table) — never the engine meta, never the game profile
|
|
707
|
+
// file above.
|
|
708
|
+
async getCharacterPlayableState(
|
|
709
|
+
slug: string,
|
|
710
|
+
assetPath: string,
|
|
711
|
+
): Promise<{
|
|
712
|
+
override: CharacterMotionOverride | null;
|
|
713
|
+
mapping: MotionMappingDraft | null;
|
|
714
|
+
delivery: PlayableDeliverySnapshot | null;
|
|
715
|
+
} | null> {
|
|
716
|
+
const { slot, fileName } = parseAssetPath(assetPath);
|
|
717
|
+
if (!slot) return null;
|
|
718
|
+
const dir = slotDir(slug, slot);
|
|
719
|
+
const sidecarAbs = await resolveExistingSidecarAbs(dir, fileName);
|
|
720
|
+
if (!sidecarAbs) return null;
|
|
721
|
+
let sidecar: AssetSidecar;
|
|
722
|
+
try {
|
|
723
|
+
sidecar = JSON.parse(await readFile(sidecarAbs, 'utf8')) as AssetSidecar;
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
726
|
+
throw error;
|
|
727
|
+
}
|
|
728
|
+
return {
|
|
729
|
+
override: sidecar.custom.playableOverride ?? null,
|
|
730
|
+
mapping: sidecar.custom.motionMapping ?? null,
|
|
731
|
+
delivery: sidecar.custom.playableDelivery ?? null,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async setCharacterPlayableOverride(
|
|
736
|
+
slug: string,
|
|
737
|
+
assetPath: string,
|
|
738
|
+
override: CharacterMotionOverride | null,
|
|
739
|
+
): Promise<Gen3DAssetManifest> {
|
|
740
|
+
return withAssetLock(`${slug}:${assetPath}`, async () => {
|
|
741
|
+
const { slot, fileName } = parseAssetPath(assetPath);
|
|
742
|
+
if (!slot) {
|
|
743
|
+
throw Object.assign(new Error(`unrecognized assetPath ${JSON.stringify(assetPath)}`), {
|
|
744
|
+
code: 'invalid_asset_path',
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
const dir = slotDir(slug, slot);
|
|
748
|
+
const sidecarAbs = await resolveExistingSidecarAbs(dir, fileName);
|
|
749
|
+
if (!sidecarAbs) {
|
|
750
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
751
|
+
}
|
|
752
|
+
let sidecar: AssetSidecar;
|
|
753
|
+
try {
|
|
754
|
+
sidecar = JSON.parse(await readFile(sidecarAbs, 'utf8')) as AssetSidecar;
|
|
755
|
+
} catch (error) {
|
|
756
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
757
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
758
|
+
}
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
761
|
+
const updated: AssetSidecar = { ...sidecar, custom: { ...sidecar.custom } };
|
|
762
|
+
if (override) updated.custom.playableOverride = override;
|
|
763
|
+
else delete updated.custom.playableOverride;
|
|
764
|
+
await writeSidecarJson(dir, fileName, updated);
|
|
765
|
+
return sidecarToManifest(slug, slot, fileName, updated);
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async setCharacterMotionMapping(
|
|
770
|
+
slug: string,
|
|
771
|
+
assetPath: string,
|
|
772
|
+
mapping: MotionMappingDraft | null,
|
|
773
|
+
): Promise<Gen3DAssetManifest> {
|
|
774
|
+
return withAssetLock(`${slug}:${assetPath}`, async () => {
|
|
775
|
+
const { slot, fileName } = parseAssetPath(assetPath);
|
|
776
|
+
if (!slot) {
|
|
777
|
+
throw Object.assign(new Error(`unrecognized assetPath ${JSON.stringify(assetPath)}`), {
|
|
778
|
+
code: 'invalid_asset_path',
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
const dir = slotDir(slug, slot);
|
|
782
|
+
const sidecarAbs = await resolveExistingSidecarAbs(dir, fileName);
|
|
783
|
+
if (!sidecarAbs) {
|
|
784
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
785
|
+
}
|
|
786
|
+
let sidecar: AssetSidecar;
|
|
787
|
+
try {
|
|
788
|
+
sidecar = JSON.parse(await readFile(sidecarAbs, 'utf8')) as AssetSidecar;
|
|
789
|
+
} catch (error) {
|
|
790
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
791
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
792
|
+
}
|
|
793
|
+
throw error;
|
|
794
|
+
}
|
|
795
|
+
const updated: AssetSidecar = { ...sidecar, custom: { ...sidecar.custom } };
|
|
796
|
+
if (mapping) updated.custom.motionMapping = mapping;
|
|
797
|
+
else delete updated.custom.motionMapping;
|
|
798
|
+
await writeSidecarJson(dir, fileName, updated);
|
|
799
|
+
return sidecarToManifest(slug, slot, fileName, updated);
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Read one file in an asset by role (+ optional format), for COS-sharing it as
|
|
804
|
+
// a provider transfer URL. The main mesh is (source_mesh, glb); rig/anim files
|
|
805
|
+
// are same-basename sidefiles recorded in the sidecar dependencies.
|
|
806
|
+
async readAssetFile(
|
|
807
|
+
slug: string,
|
|
808
|
+
assetPath: string,
|
|
809
|
+
role: FileRole,
|
|
810
|
+
format?: FileFormat,
|
|
811
|
+
): Promise<{ data: Uint8Array; format: FileFormat } | null> {
|
|
812
|
+
const manifest = await this.getAsset(slug, assetPath);
|
|
813
|
+
if (!manifest) return null;
|
|
814
|
+
const file = manifest.files.find(
|
|
815
|
+
(f) => f.role === role && (format ? f.format === format : true),
|
|
816
|
+
);
|
|
817
|
+
if (!file) return null;
|
|
818
|
+
const { slot } = parseAssetPath(file.storageKey);
|
|
819
|
+
if (!slot) return null;
|
|
820
|
+
// storageKey is "assets/3d/<slot>/<fileName>"; read by file name in the dir.
|
|
821
|
+
const fileName = file.storageKey.replace(/^assets\/3d\/[^/]+\//, '');
|
|
822
|
+
const abs = resolve(slotDir(slug, slot), fileName);
|
|
823
|
+
try {
|
|
824
|
+
const data = await readFile(abs);
|
|
825
|
+
return { data: new Uint8Array(data), format: file.format };
|
|
826
|
+
} catch (error) {
|
|
827
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
828
|
+
throw error;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ─── Scratch (transfer) artifacts — NOT assets ────────────────────────────
|
|
833
|
+
async putScratch(input: PutScratchInput): Promise<PutScratchResult> {
|
|
834
|
+
const sha256 = sha256Hex(input.data);
|
|
835
|
+
const rel = `.gen3d/tmp/${sha256}.${input.format}`;
|
|
836
|
+
const abs = resolve(gameRoot(input.slug), rel);
|
|
837
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
838
|
+
await writeFile(abs, input.data);
|
|
839
|
+
// Scratch lives under .gen3d/tmp/ (not assets/3d/**). Preview via /api/gen3d-scratch.
|
|
840
|
+
return {
|
|
841
|
+
storageKey: rel,
|
|
842
|
+
sha256,
|
|
843
|
+
bytes: input.data.byteLength,
|
|
844
|
+
localUrl: scratchUrlFor(input.slug, sha256, input.format),
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
/** Absolute path under the game root for a game-relative path (e.g. assets/characters/x.glb). */
|
|
850
|
+
resolveGameRelPath(slug: string, relPath: string): string {
|
|
851
|
+
return resolve(gameRoot(slug), relPath);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
async updatePlayableDeliverySnapshot(
|
|
855
|
+
slug: string,
|
|
856
|
+
assetPath: string,
|
|
857
|
+
snapshot: PlayableDeliverySnapshot,
|
|
858
|
+
): Promise<void> {
|
|
859
|
+
await withAssetLock(`${slug}:${assetPath}`, async () => {
|
|
860
|
+
const { slot, fileName } = parseAssetPath(assetPath);
|
|
861
|
+
if (!slot) {
|
|
862
|
+
throw Object.assign(new Error(`unrecognized assetPath ${JSON.stringify(assetPath)}`), {
|
|
863
|
+
code: 'invalid_asset_path',
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
const dir = slotDir(slug, slot);
|
|
867
|
+
const sidecarAbs = await resolveExistingSidecarAbs(dir, fileName);
|
|
868
|
+
if (!sidecarAbs) {
|
|
869
|
+
throw Object.assign(new Error(`asset not found: ${assetPath}`), { code: 'asset_not_found' });
|
|
870
|
+
}
|
|
871
|
+
const sidecar = JSON.parse(await readFile(sidecarAbs, 'utf8')) as AssetSidecar;
|
|
872
|
+
const updated: AssetSidecar = {
|
|
873
|
+
...sidecar,
|
|
874
|
+
custom: { ...sidecar.custom, playableDelivery: snapshot },
|
|
875
|
+
};
|
|
876
|
+
await writeSidecarJson(dir, fileName, updated);
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/** Resolve on-disk paths for a durable asset (used by Import to Game). */
|
|
881
|
+
resolveAssetFiles(
|
|
882
|
+
slug: string,
|
|
883
|
+
assetPath: string,
|
|
884
|
+
): { glbAbs: string; metaAbs: string; sidecarAbs: string; glbFileName: string; slot: AssetSlot } | null {
|
|
885
|
+
const { slot, fileName } = parseAssetPath(assetPath);
|
|
886
|
+
if (!slot) return null;
|
|
887
|
+
const dir = slotDir(slug, slot);
|
|
888
|
+
return {
|
|
889
|
+
slot,
|
|
890
|
+
glbFileName: fileName,
|
|
891
|
+
glbAbs: resolve(dir, fileName),
|
|
892
|
+
metaAbs: legacySidecarAbsForGlbFile(dir, fileName),
|
|
893
|
+
sidecarAbs: sidecarAbsForGlbFile(dir, fileName),
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Stable, readable on-disk file-name variant for a motion clip. Free Meshy
|
|
899
|
+
// walk/run get named variants (their ids are reserved negatives); real Meshy
|
|
900
|
+
// actions use their action_id; Hunyuan uses hy1-/hy2- prefixes.
|
|
901
|
+
function motionVariantSlug(ref: MotionRef): string {
|
|
902
|
+
if (ref.system === 'meshy') {
|
|
903
|
+
if (ref.id === MESHY_FREE_WALK_ID) return 'meshy-free-walk';
|
|
904
|
+
if (ref.id === MESHY_FREE_RUN_ID) return 'meshy-free-run';
|
|
905
|
+
return `meshy-${ref.id}`;
|
|
906
|
+
}
|
|
907
|
+
if (ref.system === 'hunyuan_v1') return `hy1-${ref.id}`;
|
|
908
|
+
if (ref.system === 'visvise') {
|
|
909
|
+
const raw = String(ref.id).replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
910
|
+
return `visvise-${raw || 'motion'}`;
|
|
911
|
+
}
|
|
912
|
+
return `hy2-${ref.id}`;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// "assets/3d/<slot>/<file>" → { slot, fileName }. Returns slot=null if the path
|
|
916
|
+
// is not a recognized 3D slot path.
|
|
917
|
+
function parseAssetPath(assetPath: string): { slot: AssetSlot | null; fileName: string } {
|
|
918
|
+
const m = /^assets\/3d\/(characters|meshes)\/([^/]+)$/.exec(assetPath);
|
|
919
|
+
if (!m) return { slot: null, fileName: '' };
|
|
920
|
+
return { slot: m[1] as AssetSlot, fileName: m[2] };
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Sidecar deps are same-directory basenames. Stat size; never follow
|
|
924
|
+
* symlinks or `..` / absolute paths — missing or unsafe → 0. */
|
|
925
|
+
function sidefileBytes(dir: string, depPath: string): number {
|
|
926
|
+
if (!depPath || depPath.includes('\0') || depPath.includes('/') || depPath.includes('\\')) return 0;
|
|
927
|
+
if (depPath === '..' || depPath === '.' || isAbsolute(depPath)) return 0;
|
|
928
|
+
const abs = resolve(dir, depPath);
|
|
929
|
+
const rel = relative(dir, abs);
|
|
930
|
+
if (!rel || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return 0;
|
|
931
|
+
try {
|
|
932
|
+
const st = lstatSync(abs);
|
|
933
|
+
if (st.isSymbolicLink() || !st.isFile()) return 0;
|
|
934
|
+
return st.size;
|
|
935
|
+
} catch {
|
|
936
|
+
return 0;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function sidecarToManifest(
|
|
941
|
+
slug: string,
|
|
942
|
+
slot: AssetSlot,
|
|
943
|
+
fileName: string,
|
|
944
|
+
sidecar: AssetSidecar,
|
|
945
|
+
): Gen3DAssetManifest {
|
|
946
|
+
const c = sidecar.custom;
|
|
947
|
+
const dir = slotDir(slug, slot);
|
|
948
|
+
const mainRel = relPath(slot, fileName);
|
|
949
|
+
const baseName = fileName.replace(/\.glb$/, '');
|
|
950
|
+
const files: ManifestFile[] = [
|
|
951
|
+
{
|
|
952
|
+
fileId: mainRel,
|
|
953
|
+
role: 'source_mesh',
|
|
954
|
+
format: 'glb',
|
|
955
|
+
storageKey: mainRel,
|
|
956
|
+
bytes: sidecar.size,
|
|
957
|
+
sha256: sidecar.contentHash.replace(/^sha256:/, ''),
|
|
958
|
+
localUrl: localUrlFor(slug, mainRel),
|
|
959
|
+
hasSkeleton: false,
|
|
960
|
+
skeletonProfile: 'unknown',
|
|
961
|
+
animationInputReady: false,
|
|
962
|
+
},
|
|
963
|
+
];
|
|
964
|
+
for (const dep of sidecar.dependencies ?? []) {
|
|
965
|
+
const rel = relPath(slot, dep.path);
|
|
966
|
+
const format = (dep.path.split('.').pop() ?? 'png') as FileFormat;
|
|
967
|
+
const role = (dep.kind as ManifestFile['role']) ?? 'preview_image';
|
|
968
|
+
// Prefer the generalized motionRef; upgrade a legacy bare motionType if
|
|
969
|
+
// that's all an older sidecar carries (back-compat, ADR-0006 §3).
|
|
970
|
+
const motionRef: MotionRef | undefined =
|
|
971
|
+
dep.motionRef ??
|
|
972
|
+
(dep.motionType !== undefined ? motionRefFromLegacy(dep.motionType as MotionType) : undefined);
|
|
973
|
+
files.push({
|
|
974
|
+
fileId: rel,
|
|
975
|
+
role,
|
|
976
|
+
format,
|
|
977
|
+
storageKey: rel,
|
|
978
|
+
bytes: sidefileBytes(dir, dep.path),
|
|
979
|
+
sha256: dep.hash.replace(/^sha256:/, ''),
|
|
980
|
+
localUrl: localUrlFor(slug, rel),
|
|
981
|
+
// Restore rig metadata from the sidecar dep (set only by a verified rig
|
|
982
|
+
// step); plain sidefiles (preview/texture) stay hasSkeleton:false.
|
|
983
|
+
hasSkeleton: dep.hasSkeleton ?? false,
|
|
984
|
+
skeletonProfile: (dep.skeletonProfile as SkeletonProfile) ?? 'unknown',
|
|
985
|
+
animationInputReady: dep.animationInputReady ?? false,
|
|
986
|
+
...(motionRef ? { motionRef } : {}),
|
|
987
|
+
// Keep the legacy field populated for hunyuan_v1 so older UI still reads it.
|
|
988
|
+
...(motionRef?.system === 'hunyuan_v1' ? { motionType: motionRef.id } : {}),
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
void baseName;
|
|
992
|
+
return {
|
|
993
|
+
manifestVersion: 1,
|
|
994
|
+
assetPath: mainRel,
|
|
995
|
+
assetSlot: slot,
|
|
996
|
+
kind: 'mesh',
|
|
997
|
+
provider: c.provider,
|
|
998
|
+
providerMode: c.providerMode,
|
|
999
|
+
mode: c.mode,
|
|
1000
|
+
sourceJobId: c.sourceJobId,
|
|
1001
|
+
sourceInputAssetPaths: c.sourceInputAssetPaths ?? [],
|
|
1002
|
+
prompt: c.prompt,
|
|
1003
|
+
userLabel: c.userLabel ?? null,
|
|
1004
|
+
files,
|
|
1005
|
+
// Always derive from the restored file set so appended rig/anim files are
|
|
1006
|
+
// reflected even when the stored custom.readiness is stale.
|
|
1007
|
+
readiness: computeReadiness(files),
|
|
1008
|
+
...(c.rig ? { rig: c.rig } : {}),
|
|
1009
|
+
...(c.meshyTaskRefs ? { meshyTaskRefs: c.meshyTaskRefs } : {}),
|
|
1010
|
+
...(c.visviseRefs ? { visviseRefs: c.visviseRefs } : {}),
|
|
1011
|
+
quality: c.quality ? reportToScore(c.quality) : emptyQuality(),
|
|
1012
|
+
targetFaceCount: c.faceCount ?? null,
|
|
1013
|
+
createdAt: sidecar.createdAt,
|
|
1014
|
+
updatedAt: sidecar.createdAt,
|
|
1015
|
+
};
|
|
1016
|
+
}
|