@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.
Files changed (110) hide show
  1. package/SOURCE_PROVENANCE.md +8 -0
  2. package/dist/assets/index-CIx3aXo1.js +4336 -0
  3. package/dist/assets/index-CIx3aXo1.js.map +1 -0
  4. package/dist/assets/index-CmhOHLEB.css +1 -0
  5. package/dist/hdr/README.md +28 -0
  6. package/dist/hdr/presets.json +8 -0
  7. package/dist/index.html +13 -0
  8. package/forgeax-extension.json +428 -0
  9. package/marketplace-card.json +56 -0
  10. package/package.json +59 -0
  11. package/packages/external-asset-meta/package.json +24 -0
  12. package/packages/external-asset-meta/src/cook.test.ts +211 -0
  13. package/packages/external-asset-meta/src/cook.ts +188 -0
  14. package/packages/external-asset-meta/src/draco3dgltf.d.ts +11 -0
  15. package/packages/external-asset-meta/src/index.ts +18 -0
  16. package/packages/external-asset-meta/src/normalize.test.ts +36 -0
  17. package/packages/external-asset-meta/src/normalize.ts +101 -0
  18. package/packages/external-asset-meta/src/types.ts +82 -0
  19. package/schemas/adopt-playable-character.args.json +26 -0
  20. package/schemas/adopt-playable-character.returns.json +31 -0
  21. package/schemas/apply-motion.args.json +47 -0
  22. package/schemas/apply-motion.returns.json +23 -0
  23. package/schemas/auto-rig.args.json +35 -0
  24. package/schemas/auto-rig.returns.json +23 -0
  25. package/schemas/delete-asset.args.json +18 -0
  26. package/schemas/delete-asset.returns.json +18 -0
  27. package/schemas/engine-import-status.args.json +18 -0
  28. package/schemas/engine-import-status.returns.json +50 -0
  29. package/schemas/export-playable-character.args.json +21 -0
  30. package/schemas/export-playable-character.returns.json +47 -0
  31. package/schemas/gen3d-asset-manifest.json +281 -0
  32. package/schemas/generate-meshy-text-mock.args.json +42 -0
  33. package/schemas/generate-meshy-text-mock.returns.json +301 -0
  34. package/schemas/get-credentials.args.json +7 -0
  35. package/schemas/get-credentials.returns.json +45 -0
  36. package/schemas/get-playable-profile.args.json +11 -0
  37. package/schemas/get-playable-profile.returns.json +188 -0
  38. package/schemas/image-to-3d.args.json +47 -0
  39. package/schemas/image-to-3d.returns.json +306 -0
  40. package/schemas/import-to-engine.args.json +18 -0
  41. package/schemas/import-to-engine.returns.json +35 -0
  42. package/schemas/list-assets.args.json +22 -0
  43. package/schemas/list-assets.returns.json +296 -0
  44. package/schemas/list-motions.args.json +29 -0
  45. package/schemas/list-motions.returns.json +41 -0
  46. package/schemas/pose-standardization.args.json +28 -0
  47. package/schemas/pose-standardization.returns.json +29 -0
  48. package/schemas/provider-status.args.json +7 -0
  49. package/schemas/provider-status.returns.json +70 -0
  50. package/schemas/refine-mesh.args.json +33 -0
  51. package/schemas/refine-mesh.returns.json +306 -0
  52. package/schemas/rename-asset.args.json +22 -0
  53. package/schemas/rename-asset.returns.json +14 -0
  54. package/schemas/retopo-lowpoly.args.json +34 -0
  55. package/schemas/retopo-lowpoly.returns.json +24 -0
  56. package/schemas/score-quality.args.json +36 -0
  57. package/schemas/score-quality.returns.json +12 -0
  58. package/schemas/set-credentials.args.json +32 -0
  59. package/schemas/set-credentials.returns.json +45 -0
  60. package/schemas/set-playable-motion-mapping.args.json +25 -0
  61. package/schemas/set-playable-motion-mapping.returns.json +33 -0
  62. package/schemas/set-playable-profile.args.json +32 -0
  63. package/schemas/set-playable-profile.returns.json +67 -0
  64. package/schemas/text-to-3d.args.json +51 -0
  65. package/schemas/text-to-3d.returns.json +306 -0
  66. package/schemas/upload-image.args.json +19 -0
  67. package/schemas/upload-image.returns.json +21 -0
  68. package/schemas/upload-video.args.json +14 -0
  69. package/schemas/upload-video.returns.json +14 -0
  70. package/schemas/views-to-3d.args.json +58 -0
  71. package/schemas/views-to-3d.returns.json +306 -0
  72. package/server/adopt-playable-character.ts +291 -0
  73. package/server/asset-storage.ts +136 -0
  74. package/server/asset-upload.ts +123 -0
  75. package/server/audit.ts +58 -0
  76. package/server/cache.ts +75 -0
  77. package/server/cos-uploader.ts +141 -0
  78. package/server/credentials-store.ts +186 -0
  79. package/server/engine-import.ts +238 -0
  80. package/server/env.ts +287 -0
  81. package/server/export-playable-character.ts +364 -0
  82. package/server/generate.ts +89 -0
  83. package/server/merge-playable-character.ts +259 -0
  84. package/server/motion-catalog.ts +137 -0
  85. package/server/per-game-store.ts +1016 -0
  86. package/server/providers/gateway-client.ts +171 -0
  87. package/server/providers/gateway-data.ts +182 -0
  88. package/server/providers/gateway-models.ts +49 -0
  89. package/server/providers/hunyuan-rest.ts +317 -0
  90. package/server/providers/hunyuan-workflow.ts +225 -0
  91. package/server/providers/meshy-direct-client.ts +223 -0
  92. package/server/providers/meshy.ts +548 -0
  93. package/server/providers/rodin-gateway.ts +193 -0
  94. package/server/providers/rodin.ts +253 -0
  95. package/server/providers/visvise.ts +329 -0
  96. package/server/rate-guard.ts +29 -0
  97. package/server/tool-handlers.ts +2193 -0
  98. package/shared/catalog.ts +307 -0
  99. package/shared/manifest.ts +378 -0
  100. package/shared/meshy-actions.ts +690 -0
  101. package/shared/playable-preview-url.test.ts +14 -0
  102. package/shared/playable-preview-url.ts +13 -0
  103. package/shared/playable-profile.test.ts +127 -0
  104. package/shared/playable-profile.ts +171 -0
  105. package/shared/provider-params.test.ts +172 -0
  106. package/shared/provider-params.ts +389 -0
  107. package/shared/quality/heuristics.test.ts +84 -0
  108. package/shared/quality/heuristics.ts +100 -0
  109. package/shared/rodin-image.test.ts +51 -0
  110. package/shared/rodin-image.ts +125 -0
@@ -0,0 +1,136 @@
1
+ // AssetStorage — adapter boundary for durable per-game 3D asset persistence.
2
+ //
3
+ // M9 (ADR-0002): an asset is a named file in the active game's runtime asset
4
+ // library, identified by its game-relative path (assetPath). The adapter owns
5
+ // all path logic (write file + sidecar, list, delete, name collision) so a
6
+ // future path change touches one place. It does NOT know which provider
7
+ // produced an asset (decoupling rule, ADR-0001). The local impl writes to
8
+ // .forgeax/games/<slug>/assets/3d/{characters|meshes}/; a future COS/S3 adapter
9
+ // could swap without changing this interface or the manifest contract.
10
+
11
+ import type { AssetSlot, FileFormat, FileRole, Gen3DAssetManifest, MotionRef, QualityReport, RigChain, SkeletonProfile } from '../shared/manifest';
12
+
13
+ // One produced file (already downloaded into bytes; never a provider URL).
14
+ export interface AssetFileInput {
15
+ data: Uint8Array;
16
+ format: FileFormat;
17
+ role: FileRole;
18
+ }
19
+
20
+ export interface WriteAssetInput {
21
+ slug: string;
22
+ assetSlot: AssetSlot;
23
+ // Desired base name (without extension). The store sanitizes it and, on a
24
+ // non-cache name collision, appends a numeric suffix instead of overwriting.
25
+ assetName: string;
26
+ files: AssetFileInput[];
27
+ // gen3d-private metadata persisted under the sidecar `custom` namespace.
28
+ meta: {
29
+ provider: Gen3DAssetManifest['provider'];
30
+ providerMode: Gen3DAssetManifest['providerMode'];
31
+ mode: Gen3DAssetManifest['mode'];
32
+ sourceJobId: string | null;
33
+ prompt: string | null;
34
+ sourceInputAssetPaths: string[];
35
+ faceCount?: number;
36
+ cacheKey?: string;
37
+ meshyTaskRefs?: Gen3DAssetManifest['meshyTaskRefs'];
38
+ visviseRefs?: Gen3DAssetManifest['visviseRefs'];
39
+ };
40
+ }
41
+
42
+ export interface AssetStorage {
43
+ // Persist a full asset (main GLB + same-basename sidefiles + sidecar) into the
44
+ // game's asset tree and return the resulting manifest (assetPath = main GLB).
45
+ writeAsset(input: WriteAssetInput): Promise<Gen3DAssetManifest>;
46
+ // Load a manifest by game-relative assetPath (reads the sidecar).
47
+ getAsset(slug: string, assetPath: string): Promise<Gen3DAssetManifest | null>;
48
+ // List manifests in a game, optionally filtered by slot (scans the directory).
49
+ listAssets(slug: string, assetSlot?: AssetSlot): Promise<Gen3DAssetManifest[]>;
50
+ // Delete the asset file + sidecar + same-basename sidefiles. Returns the
51
+ // cacheKey recorded in the sidecar (for a cache tombstone) or null.
52
+ deleteAsset(slug: string, assetPath: string): Promise<{ cacheKey: string | null }>;
53
+ // Append derived files (rigged_model / animated_model GLB+FBX) to an existing
54
+ // mesh asset as same-basename sidefiles, update the sidecar dependencies +
55
+ // skeleton/motion metadata, recompute readiness, and return the updated
56
+ // manifest. Serialized per asset (read-modify-write) so concurrent appends to
57
+ // one character (e.g. multiple motions) never drop entries (ADR-0003). The
58
+ // main GLB identity (assetPath) is unchanged.
59
+ appendDerivedFiles(input: AppendDerivedFilesInput): Promise<Gen3DAssetManifest>;
60
+ // Strip every rigged_model / animated_model sidefile + clear custom.rig so a
61
+ // forced re-rig (possibly on another provider) starts from a clean mesh.
62
+ // Source mesh / preview / textures are kept. Main GLB identity unchanged.
63
+ clearRigAndMotions(slug: string, assetPath: string): Promise<Gen3DAssetManifest>;
64
+ // Persist the five-dimension quality report into the asset sidecar
65
+ // (custom.quality) and return the refreshed manifest.
66
+ updateAssetQuality(
67
+ slug: string,
68
+ assetPath: string,
69
+ report: QualityReport,
70
+ ): Promise<Gen3DAssetManifest>;
71
+ // Persist a user-defined display label into the asset sidecar
72
+ // (custom.userLabel) and return the refreshed manifest.
73
+ updateAssetLabel(
74
+ slug: string,
75
+ assetPath: string,
76
+ label: string | null,
77
+ ): Promise<Gen3DAssetManifest>;
78
+ // Read the bytes of one file in an asset, selected by role (+ optional format),
79
+ // for COS-sharing it as a provider transfer URL. Returns null when absent.
80
+ readAssetFile(
81
+ slug: string,
82
+ assetPath: string,
83
+ role: FileRole,
84
+ format?: FileFormat,
85
+ ): Promise<{ data: Uint8Array; format: FileFormat } | null>;
86
+ // Persist a transfer/scratch artifact (pose-standardized image, uploaded
87
+ // input). NOT an asset: lives under .gen3d/tmp/, no manifest, no delete UI.
88
+ putScratch(input: PutScratchInput): Promise<PutScratchResult>;
89
+ }
90
+
91
+ // One derived file to append to an existing asset. Bytes already downloaded.
92
+ export interface DerivedFileInput {
93
+ data: Uint8Array;
94
+ format: FileFormat;
95
+ // rigged_model | animated_model (the appended downstream roles).
96
+ role: FileRole;
97
+ // For animated_model files: which motion this clip is (any system). Stored
98
+ // structurally + used as the on-disk file-name variant for readability. A
99
+ // single append may carry several files with different motionRefs (e.g. a rig
100
+ // step that also lands the free walk/run clips, ADR-0006 §8-Q6).
101
+ motionRef?: MotionRef;
102
+ }
103
+
104
+ export interface AppendDerivedFilesInput {
105
+ slug: string;
106
+ // Target mesh asset to append onto (its main GLB stays the identity).
107
+ assetPath: string;
108
+ files: DerivedFileInput[];
109
+ // Skeleton metadata applied to rigged_model files (a verified rig step).
110
+ skeleton?: {
111
+ hasSkeleton: boolean;
112
+ skeletonProfile: SkeletonProfile;
113
+ animationInputReady: boolean;
114
+ };
115
+ // Rig-chain identity (provider + Meshy rig_task_id + rigType + expiry),
116
+ // persisted on the asset so apply-motion can dispatch by system (ADR-0006).
117
+ rigChain?: RigChain;
118
+ visviseRefs?: Gen3DAssetManifest['visviseRefs'];
119
+ }
120
+
121
+ // Scratch (transfer) artifacts — pose-standardized images, uploaded inputs. NOT
122
+ // assets: they live under .forgeax/games/<slug>/.gen3d/tmp/, never in the asset
123
+ // library, and have no delete UI (CONTEXT.md "临时/中转产物").
124
+ export interface PutScratchInput {
125
+ slug: string;
126
+ data: Uint8Array;
127
+ format: FileFormat;
128
+ }
129
+
130
+ export interface PutScratchResult {
131
+ // Scratch storage key (relative to the project root) for same-origin preview.
132
+ storageKey: string;
133
+ sha256: string;
134
+ bytes: number;
135
+ localUrl: string | null;
136
+ }
@@ -0,0 +1,123 @@
1
+ // Transfer-artifact upload. Input images / mesh files exist only so a
2
+ // URL-fetching provider can reach a local file.
3
+ //
4
+ // Preferred path (2026-08 group contract): POST the bytes to the LiteLLM
5
+ // virtual-key endpoint `/v1/assets` (`purpose=model_input`). The returned URL
6
+ // is valid for 24h. Fallback: plugin-local COS_* via CosUploader.
7
+ //
8
+ // Video still uses CosUploader.presignPut — the gateway contract is POST file,
9
+ // not a browser presign.
10
+
11
+ import { createHash } from 'node:crypto';
12
+
13
+ import { CosUploader, extForMime, type CosUploadResult } from './cos-uploader';
14
+ import { getCosEnv, getLitellmEnv, type LitellmEnv } from './env';
15
+ import { httpError, type FetchLike } from './providers/gateway-client';
16
+
17
+ const DEFAULT_EXPIRES_SEC = 24 * 3600;
18
+
19
+ export function extractAssetUrl(resp: Record<string, unknown>): { url: string; expiresInSec: number } | null {
20
+ const url = firstString(
21
+ resp.url,
22
+ resp.signed_url,
23
+ resp.asset_url,
24
+ resp.file_url,
25
+ nestedString(resp.data, 'url'),
26
+ nestedString(resp.file, 'url'),
27
+ firstArrayUrl(resp.data),
28
+ );
29
+ if (!url) return null;
30
+ const expiresInSec = firstPositiveInt(resp.expires_in, resp.expiresInSec, resp.expires_in_sec) ?? DEFAULT_EXPIRES_SEC;
31
+ return { url, expiresInSec };
32
+ }
33
+
34
+ export async function uploadViaLitellmAssets(opts: {
35
+ env: LitellmEnv;
36
+ data: Uint8Array;
37
+ mimetype: string;
38
+ filename?: string;
39
+ fetchImpl?: FetchLike;
40
+ }): Promise<CosUploadResult> {
41
+ const filename = opts.filename ?? `upload.${extForMime(opts.mimetype)}`;
42
+ const form = new FormData();
43
+ form.append(
44
+ 'file',
45
+ new Blob([Uint8Array.from(opts.data)], { type: opts.mimetype }),
46
+ filename,
47
+ );
48
+ form.append('purpose', 'model_input');
49
+ const fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
50
+ const resp = await fetchImpl(`${opts.env.baseUrl.replace(/\/+$/, '')}/v1/assets`, {
51
+ method: 'POST',
52
+ headers: { Authorization: `Bearer ${opts.env.apiKey}` },
53
+ body: form,
54
+ });
55
+ if (!resp.ok) {
56
+ const body = await resp.text().catch(() => '');
57
+ throw httpError(resp.status, body);
58
+ }
59
+ const json = (await resp.json()) as Record<string, unknown>;
60
+ const extracted = extractAssetUrl(json);
61
+ if (!extracted) {
62
+ throw Object.assign(new Error('LiteLLM /v1/assets returned no url'), { code: 'cos_not_configured' });
63
+ }
64
+ return {
65
+ url: extracted.url,
66
+ bytes: opts.data.byteLength,
67
+ sha256: createHash('sha256').update(opts.data).digest('hex'),
68
+ expiresInSec: extracted.expiresInSec,
69
+ };
70
+ }
71
+
72
+ export async function uploadTransferArtifact(
73
+ data: Uint8Array,
74
+ mimetype: string,
75
+ fetchImpl?: FetchLike,
76
+ ): Promise<CosUploadResult> {
77
+ const litellm = getLitellmEnv();
78
+ const cos = getCosEnv();
79
+ if (litellm) {
80
+ try {
81
+ return await uploadViaLitellmAssets({ env: litellm, data, mimetype, fetchImpl });
82
+ } catch (error) {
83
+ if (cos) return new CosUploader(cos).upload(data, mimetype);
84
+ throw Object.assign(
85
+ new Error('file upload failed (LiteLLM /v1/assets) and COS is not configured'),
86
+ { code: 'cos_not_configured', cause: error },
87
+ );
88
+ }
89
+ }
90
+ if (!cos) {
91
+ throw Object.assign(
92
+ new Error('file upload is not configured (LiteLLM /v1/assets or COS)'),
93
+ { code: 'cos_not_configured' },
94
+ );
95
+ }
96
+ return new CosUploader(cos).upload(data, mimetype);
97
+ }
98
+
99
+ function firstString(...values: unknown[]): string | null {
100
+ for (const value of values) {
101
+ if (typeof value === 'string' && value.trim()) return value.trim();
102
+ }
103
+ return null;
104
+ }
105
+
106
+ function nestedString(obj: unknown, key: string): string | null {
107
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) return null;
108
+ const value = (obj as Record<string, unknown>)[key];
109
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
110
+ }
111
+
112
+ function firstArrayUrl(value: unknown): string | null {
113
+ if (!Array.isArray(value) || value.length === 0) return null;
114
+ return nestedString(value[0], 'url');
115
+ }
116
+
117
+ function firstPositiveInt(...values: unknown[]): number | null {
118
+ for (const value of values) {
119
+ const n = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseInt(value, 10) : NaN;
120
+ if (Number.isFinite(n) && n > 0) return n;
121
+ }
122
+ return null;
123
+ }
@@ -0,0 +1,58 @@
1
+ // Audit — append-only JSONL trail of provider activity, per-game (ADR-0002).
2
+ // Records timing and outcome only; NEVER writes the api key, full request
3
+ // payload, or raw provider response. Lives under the game's .gen3d/ dir so it
4
+ // stays out of source control and is removed when the game is deleted.
5
+
6
+ import { appendFile, mkdir } from 'node:fs/promises';
7
+ import { dirname, resolve } from 'node:path';
8
+
9
+ import type { GenerationMode, ProviderId } from '../shared/manifest';
10
+
11
+ export type AuditEvent =
12
+ | 'submit'
13
+ | 'poll_succeeded'
14
+ | 'poll_failed'
15
+ | 'poll_timeout'
16
+ | 'cache_hit'
17
+ | 'rate_blocked'
18
+ | 'rest_succeeded'
19
+ | 'rest_failed'
20
+ | 'rest_no_output'
21
+ | 'asset_deleted';
22
+
23
+ export interface AuditRecord {
24
+ ts: string;
25
+ provider: ProviderId;
26
+ mode: GenerationMode;
27
+ event: AuditEvent;
28
+ sourceJobId?: string | null;
29
+ assetPath?: string;
30
+ cacheKey?: string;
31
+ model?: string;
32
+ httpStatus?: number;
33
+ durationMs?: number;
34
+ errorCode?: string;
35
+ // Short, non-secret detail (e.g. status string, error class). Never payloads.
36
+ detail?: string;
37
+ }
38
+
39
+ function projectRoot(): string {
40
+ return process.env.FORGEAX_PROJECT_ROOT ?? resolve(process.cwd(), '.forgeax-runtime');
41
+ }
42
+
43
+ function safeSlug(slug: string): string {
44
+ if (!slug || slug.includes('/') || slug.includes('\\') || slug === '..' || slug.includes('\0')) {
45
+ throw Object.assign(new Error(`unsafe slug ${JSON.stringify(slug)}`), { code: 'invalid_slug' });
46
+ }
47
+ return slug;
48
+ }
49
+
50
+ function auditPath(slug: string): string {
51
+ return resolve(projectRoot(), '.forgeax', 'games', safeSlug(slug), '.gen3d', 'audit.jsonl');
52
+ }
53
+
54
+ export async function audit(slug: string, record: AuditRecord): Promise<void> {
55
+ const path = auditPath(slug);
56
+ await mkdir(dirname(path), { recursive: true });
57
+ await appendFile(path, `${JSON.stringify(record)}\n`, 'utf8');
58
+ }
@@ -0,0 +1,75 @@
1
+ // Cache — request-level dedup, per-game (ADR-0002 / CONTEXT.md). Stores ONLY
2
+ // cacheKey -> assetPath mappings (+ delete tombstones), never provider responses
3
+ // or URLs. On hit, the caller loads the manifest from the per-game store by
4
+ // assetPath, so a hit never returns a dead provider URL.
5
+ //
6
+ // Per-game: cache.jsonl lives under .forgeax/games/<slug>/.gen3d/ so game-A and
7
+ // game-B never mis-hit each other (entries hold game-relative paths). The cache
8
+ // is naturally slug-isolated, so cacheKey need not include the slug, and
9
+ // deleting a game removes its cache with the directory.
10
+ //
11
+ // Append-only JSONL; last write for a key wins. A tombstone ({cacheKey,
12
+ // deleted:true}) written by gen3d:delete-asset makes lookup() miss so a
13
+ // deliberately deleted asset never resurrects / re-burns quota.
14
+
15
+ import { mkdir, readFile, appendFile } from 'node:fs/promises';
16
+ import { dirname, resolve } from 'node:path';
17
+
18
+ function projectRoot(): string {
19
+ return process.env.FORGEAX_PROJECT_ROOT ?? resolve(process.cwd(), '.forgeax-runtime');
20
+ }
21
+
22
+ function safeSlug(slug: string): string {
23
+ if (!slug || slug.includes('/') || slug.includes('\\') || slug === '..' || slug.includes('\0')) {
24
+ throw Object.assign(new Error(`unsafe slug ${JSON.stringify(slug)}`), { code: 'invalid_slug' });
25
+ }
26
+ return slug;
27
+ }
28
+
29
+ function cachePath(slug: string): string {
30
+ return resolve(projectRoot(), '.forgeax', 'games', safeSlug(slug), '.gen3d', 'cache.jsonl');
31
+ }
32
+
33
+ interface CacheEntry {
34
+ cacheKey: string;
35
+ assetPath?: string;
36
+ deleted?: boolean;
37
+ }
38
+
39
+ // Returns the live assetPath for a cacheKey, or null if unseen or tombstoned.
40
+ export async function lookup(slug: string, cacheKey: string): Promise<string | null> {
41
+ let raw: string;
42
+ try {
43
+ raw = await readFile(cachePath(slug), 'utf8');
44
+ } catch (error) {
45
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
46
+ throw error;
47
+ }
48
+ let hit: string | null = null;
49
+ for (const line of raw.split('\n')) {
50
+ if (!line.trim()) continue;
51
+ const entry = JSON.parse(line) as CacheEntry;
52
+ if (entry.cacheKey !== cacheKey) continue;
53
+ // Last write wins: a later tombstone clears an earlier mapping, and a later
54
+ // mapping (regenerate after delete) clears an earlier tombstone.
55
+ hit = entry.deleted ? null : (entry.assetPath ?? null);
56
+ }
57
+ return hit;
58
+ }
59
+
60
+ // Append a mapping only AFTER provider call + file write all succeeded
61
+ // (write-after-success).
62
+ export async function remember(slug: string, cacheKey: string, assetPath: string): Promise<void> {
63
+ const path = cachePath(slug);
64
+ await mkdir(dirname(path), { recursive: true });
65
+ const entry: CacheEntry = { cacheKey, assetPath };
66
+ await appendFile(path, `${JSON.stringify(entry)}\n`, 'utf8');
67
+ }
68
+
69
+ // Append a tombstone so a deleted asset's cacheKey no longer resolves.
70
+ export async function tombstone(slug: string, cacheKey: string): Promise<void> {
71
+ const path = cachePath(slug);
72
+ await mkdir(dirname(path), { recursive: true });
73
+ const entry: CacheEntry = { cacheKey, deleted: true };
74
+ await appendFile(path, `${JSON.stringify(entry)}\n`, 'utf8');
75
+ }
@@ -0,0 +1,141 @@
1
+ // COS uploader — hosts a local input image on cloud object storage (COS) and returns a
2
+ // time-limited presigned URL. Input images are TRANSFER artifacts, not assets:
3
+ // they exist only so a provider (Hunyuan/Meshy) whose API fetches by URL can
4
+ // reach a user's local file. Rodin takes bytes directly and never needs this.
5
+ //
6
+ // The key is content-addressed (sha256) under a fixed prefix so repeated
7
+ // uploads of the same image are idempotent. Nothing here is logged.
8
+
9
+ import COS from 'cos-nodejs-sdk-v5';
10
+ import { createHash, randomUUID } from 'node:crypto';
11
+
12
+ import type { CosEnv } from './env';
13
+
14
+ const KEY_PREFIX = 'gen3d/inputs';
15
+
16
+ export interface CosUploadResult {
17
+ url: string;
18
+ bytes: number;
19
+ sha256: string;
20
+ // Seconds until the presigned URL expires (from now).
21
+ expiresInSec: number;
22
+ }
23
+
24
+ function sha256Hex(data: Uint8Array): string {
25
+ return createHash('sha256').update(data).digest('hex');
26
+ }
27
+
28
+ // Map a small set of input mimetypes to a file extension for the COS key.
29
+ // Covers images (pose/views inputs) and 3D models (rig/motion transfer URLs).
30
+ // Unknown types fall back to .bin; the presigned URL still works for fetchers.
31
+ export function extForMime(mime: string): string {
32
+ switch (mime.toLowerCase()) {
33
+ case 'image/png':
34
+ return 'png';
35
+ case 'image/jpeg':
36
+ case 'image/jpg':
37
+ return 'jpg';
38
+ case 'image/webp':
39
+ return 'webp';
40
+ case 'image/gif':
41
+ return 'gif';
42
+ case 'model/gltf-binary':
43
+ return 'glb';
44
+ case 'model/fbx':
45
+ case 'application/octet-stream':
46
+ return 'fbx';
47
+ case 'video/mp4':
48
+ return 'mp4';
49
+ case 'video/webm':
50
+ return 'webm';
51
+ default:
52
+ return 'bin';
53
+ }
54
+ }
55
+
56
+ // Mimetype to send when COS-hosting a model file of a known format, so the
57
+ // content-addressed key carries the right extension for URL-fetching providers.
58
+ export function mimeForModelFormat(format: 'glb' | 'fbx'): string {
59
+ return format === 'glb' ? 'model/gltf-binary' : 'model/fbx';
60
+ }
61
+
62
+ export class CosUploader {
63
+ private readonly env: CosEnv;
64
+ private readonly cos: COS;
65
+
66
+ constructor(env: CosEnv) {
67
+ this.env = env;
68
+ this.cos = new COS({ SecretId: env.secretId, SecretKey: env.secretKey });
69
+ }
70
+
71
+ async upload(data: Uint8Array, mimetype: string): Promise<CosUploadResult> {
72
+ const sha256 = sha256Hex(data);
73
+ const key = `${KEY_PREFIX}/${sha256}.${extForMime(mimetype)}`;
74
+ const body = Buffer.from(data);
75
+
76
+ await new Promise<void>((resolvePut, rejectPut) => {
77
+ this.cos.putObject(
78
+ {
79
+ Bucket: this.env.bucket,
80
+ Region: this.env.region,
81
+ Key: key,
82
+ Body: body,
83
+ ContentType: mimetype,
84
+ },
85
+ (err) => (err ? rejectPut(err) : resolvePut()),
86
+ );
87
+ });
88
+
89
+ const url = await new Promise<string>((resolveUrl, rejectUrl) => {
90
+ this.cos.getObjectUrl(
91
+ {
92
+ Bucket: this.env.bucket,
93
+ Region: this.env.region,
94
+ Key: key,
95
+ Sign: true,
96
+ Expires: this.env.signExpiresSec,
97
+ },
98
+ (err, dataOut) => (err ? rejectUrl(err) : resolveUrl(dataOut.Url)),
99
+ );
100
+ });
101
+
102
+ return { url, bytes: data.byteLength, sha256, expiresInSec: this.env.signExpiresSec };
103
+ }
104
+
105
+ async presignPut(mimetype: string): Promise<{
106
+ uploadUrl: string;
107
+ publicUrl: string;
108
+ expiresInSec: number;
109
+ key: string;
110
+ }> {
111
+ const key = `${KEY_PREFIX}/video/${randomUUID()}.${extForMime(mimetype)}`;
112
+ const expiresInSec = this.env.signExpiresSec;
113
+ const uploadUrl = await new Promise<string>((resolveUrl, rejectUrl) => {
114
+ this.cos.getObjectUrl(
115
+ {
116
+ Bucket: this.env.bucket,
117
+ Region: this.env.region,
118
+ Key: key,
119
+ Method: 'PUT',
120
+ Sign: true,
121
+ Expires: expiresInSec,
122
+ Headers: { 'Content-Type': mimetype },
123
+ },
124
+ (err, dataOut) => (err ? rejectUrl(err) : resolveUrl(dataOut.Url)),
125
+ );
126
+ });
127
+ const publicUrl = await new Promise<string>((resolveUrl, rejectUrl) => {
128
+ this.cos.getObjectUrl(
129
+ {
130
+ Bucket: this.env.bucket,
131
+ Region: this.env.region,
132
+ Key: key,
133
+ Sign: true,
134
+ Expires: expiresInSec,
135
+ },
136
+ (err, dataOut) => (err ? rejectUrl(err) : resolveUrl(dataOut.Url)),
137
+ );
138
+ });
139
+ return { uploadUrl, publicUrl, expiresInSec, key };
140
+ }
141
+ }