@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,14 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { playableDeliveryLocalUrl } from './playable-preview-url';
|
|
3
|
+
|
|
4
|
+
test('playableDeliveryLocalUrl uses engine /preview mount, not /api/game-assets/3d', () => {
|
|
5
|
+
expect(playableDeliveryLocalUrl('hellforge', 'assets/characters/gta-01-merged.glb')).toBe(
|
|
6
|
+
'/preview/.forgeax/games/hellforge/assets/characters/gta-01-merged.glb',
|
|
7
|
+
);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('playableDeliveryLocalUrl encodes path segments', () => {
|
|
11
|
+
expect(playableDeliveryLocalUrl('my game', 'assets/characters/hero merged.glb')).toBe(
|
|
12
|
+
'/preview/.forgeax/games/my%20game/assets/characters/hero%20merged.glb',
|
|
13
|
+
);
|
|
14
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Preview URL for playable delivery GLBs (PLAN §5.8 PREV1).
|
|
2
|
+
//
|
|
3
|
+
// Delivery trio lives at `assets/characters/<stem>-merged.glb`, OUTSIDE the
|
|
4
|
+
// server's `/api/game-assets/:slug/3d/**` mount (that route only serves gen3d
|
|
5
|
+
// source assets under assets/3d/). The Play/Edit engine vite already exposes
|
|
6
|
+
// the game tree at `/preview/.forgeax/games/<slug>/…` (proxied by Studio UI),
|
|
7
|
+
// so ModelViewer must use that path — not /api/game-assets/…/characters/….
|
|
8
|
+
|
|
9
|
+
export function playableDeliveryLocalUrl(slug: string, modelPath: string): string {
|
|
10
|
+
const cleaned = modelPath.replace(/^\/+/, '');
|
|
11
|
+
const parts = cleaned.split('/').filter(Boolean).map(encodeURIComponent);
|
|
12
|
+
return `/preview/.forgeax/games/${encodeURIComponent(slug)}/${parts.join('/')}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
BUILTIN_PROFILE_PRESETS,
|
|
4
|
+
autoMatchMotionMapping,
|
|
5
|
+
effectiveSlots,
|
|
6
|
+
findPreset,
|
|
7
|
+
gameProfileFromPreset,
|
|
8
|
+
type CharacterMotionOverride,
|
|
9
|
+
} from './playable-profile';
|
|
10
|
+
|
|
11
|
+
test('four built-in presets exist with the exact PROF2 ids', () => {
|
|
12
|
+
const ids = BUILTIN_PROFILE_PRESETS.map((p) => p.profileId);
|
|
13
|
+
expect(ids).toEqual([
|
|
14
|
+
'basic-character-v1',
|
|
15
|
+
'action-adventure-v1',
|
|
16
|
+
'platformer-v1',
|
|
17
|
+
'blank-custom-v1',
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('blank-custom-v1 has zero slots (PROF5 enforced at export time, not here)', () => {
|
|
22
|
+
const preset = findPreset('blank-custom-v1');
|
|
23
|
+
expect(preset?.slots).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('basic-character-v1 has idle+move both required, move defaults remove_xz', () => {
|
|
27
|
+
const preset = findPreset('basic-character-v1');
|
|
28
|
+
const idle = preset?.slots.find((s) => s.slotId === 'idle');
|
|
29
|
+
const move = preset?.slots.find((s) => s.slotId === 'move');
|
|
30
|
+
expect(idle?.required).toBe(true);
|
|
31
|
+
expect(idle?.rootMotion).toBe('preserve');
|
|
32
|
+
expect(move?.required).toBe(true);
|
|
33
|
+
expect(move?.rootMotion).toBe('remove_xz');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('action-adventure-v1: idle/move/attack required, hit/death optional', () => {
|
|
37
|
+
const preset = findPreset('action-adventure-v1')!;
|
|
38
|
+
const required = preset.slots.filter((s) => s.required).map((s) => s.slotId);
|
|
39
|
+
const optional = preset.slots.filter((s) => !s.required).map((s) => s.slotId);
|
|
40
|
+
expect(required.sort()).toEqual(['attack', 'idle', 'move']);
|
|
41
|
+
expect(optional.sort()).toEqual(['death', 'hit']);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('platformer-v1: idle/move/jump required, fall/land optional', () => {
|
|
45
|
+
const preset = findPreset('platformer-v1')!;
|
|
46
|
+
const required = preset.slots.filter((s) => s.required).map((s) => s.slotId);
|
|
47
|
+
const optional = preset.slots.filter((s) => !s.required).map((s) => s.slotId);
|
|
48
|
+
expect(required.sort()).toEqual(['idle', 'jump', 'move']);
|
|
49
|
+
expect(optional.sort()).toEqual(['fall', 'land']);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('findPreset returns null for an unknown id', () => {
|
|
53
|
+
expect(findPreset('nonexistent-v1')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('gameProfileFromPreset starts at profileVersion 1 and clones preset slots (no shared references)', () => {
|
|
57
|
+
const now = '2026-07-13T00:00:00.000Z';
|
|
58
|
+
const profile = gameProfileFromPreset('basic-character-v1', now);
|
|
59
|
+
expect(profile.profileVersion).toBe(1);
|
|
60
|
+
expect(profile.profileId).toBe('basic-character-v1');
|
|
61
|
+
expect(profile.updatedAt).toBe(now);
|
|
62
|
+
profile.slots[0].displayName = 'mutated';
|
|
63
|
+
expect(findPreset('basic-character-v1')!.slots[0].displayName).not.toBe('mutated');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('gameProfileFromPreset clones matchKeywords arrays too, not just the slot object', () => {
|
|
67
|
+
const profile = gameProfileFromPreset('basic-character-v1', '2026-07-13T00:00:00.000Z');
|
|
68
|
+
profile.slots[0].matchKeywords.push('leaked');
|
|
69
|
+
expect(findPreset('basic-character-v1')!.slots[0].matchKeywords).not.toContain('leaked');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('gameProfileFromPreset falls back to the first preset for an unknown id', () => {
|
|
73
|
+
const profile = gameProfileFromPreset('nonexistent-v1', '2026-07-13T00:00:00.000Z');
|
|
74
|
+
expect(profile.profileId).toBe(BUILTIN_PROFILE_PRESETS[0].profileId);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('effectiveSlots: no override → game profile slots', () => {
|
|
78
|
+
const profile = gameProfileFromPreset('action-adventure-v1', '2026-07-13T00:00:00.000Z');
|
|
79
|
+
expect(effectiveSlots(profile, null)).toBe(profile.slots);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('effectiveSlots: override REPLACES wholesale, does not merge by slotId', () => {
|
|
83
|
+
const profile = gameProfileFromPreset('action-adventure-v1', '2026-07-13T00:00:00.000Z');
|
|
84
|
+
const override: CharacterMotionOverride = {
|
|
85
|
+
schemaVersion: 1,
|
|
86
|
+
// Deliberately drops 'hit'/'death' present in the game default — a merge-
|
|
87
|
+
// by-slotId implementation would resurrect them; effectiveSlots must not.
|
|
88
|
+
slots: [
|
|
89
|
+
{ slotId: 'idle', displayName: '待机', required: true, playbackMode: 'loop', speed: 1, matchKeywords: ['idle'], rootMotion: 'preserve' },
|
|
90
|
+
],
|
|
91
|
+
basedOnProfileId: profile.profileId,
|
|
92
|
+
basedOnProfileVersion: profile.profileVersion,
|
|
93
|
+
updatedAt: '2026-07-13T00:00:00.000Z',
|
|
94
|
+
};
|
|
95
|
+
const result = effectiveSlots(profile, override);
|
|
96
|
+
expect(result).toEqual(override.slots);
|
|
97
|
+
expect(result.some((s) => s.slotId === 'hit')).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('autoMatchMotionMapping: matches by case-insensitive keyword substring', () => {
|
|
101
|
+
const preset = findPreset('basic-character-v1')!;
|
|
102
|
+
const result = autoMatchMotionMapping(preset.slots, [
|
|
103
|
+
{ motionRefKey: 'meshy:1', label: 'Idle_Breathing' },
|
|
104
|
+
{ motionRefKey: 'meshy:2', label: 'Walking_Move_Forward' },
|
|
105
|
+
]);
|
|
106
|
+
expect(result.find((r) => r.slotId === 'idle')?.motionRefKey).toBe('meshy:1');
|
|
107
|
+
expect(result.find((r) => r.slotId === 'move')?.motionRefKey).toBe('meshy:2');
|
|
108
|
+
expect(result.every((r) => r.autoMatched)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('autoMatchMotionMapping: unmatched slot gets null motionRefKey', () => {
|
|
112
|
+
const preset = findPreset('basic-character-v1')!;
|
|
113
|
+
const result = autoMatchMotionMapping(preset.slots, [
|
|
114
|
+
{ motionRefKey: 'meshy:9', label: 'Totally_Unrelated_Clip' },
|
|
115
|
+
]);
|
|
116
|
+
expect(result.find((r) => r.slotId === 'idle')?.motionRefKey).toBeNull();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('autoMatchMotionMapping: MAP3 allows the same candidate to fill multiple slots', () => {
|
|
120
|
+
const slots = [
|
|
121
|
+
{ slotId: 'idle', displayName: '待机', required: true, playbackMode: 'loop' as const, speed: 1, matchKeywords: ['generic'], rootMotion: 'preserve' as const },
|
|
122
|
+
{ slotId: 'move', displayName: '移动', required: true, playbackMode: 'loop' as const, speed: 1, matchKeywords: ['generic'], rootMotion: 'remove_xz' as const },
|
|
123
|
+
];
|
|
124
|
+
const result = autoMatchMotionMapping(slots, [{ motionRefKey: 'meshy:1', label: 'Generic_Clip' }]);
|
|
125
|
+
expect(result[0].motionRefKey).toBe('meshy:1');
|
|
126
|
+
expect(result[1].motionRefKey).toBe('meshy:1');
|
|
127
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Playable-character motion profile domain model (PLAN-2026-07-13 §4.1/§4.2,
|
|
2
|
+
// ADR-0008). Four-layer model:
|
|
3
|
+
// built-in preset → game default profile → character override → motion mapping
|
|
4
|
+
//
|
|
5
|
+
// Storage (SSOT, PLAN §4.1 table):
|
|
6
|
+
// - presets: pure data, this file
|
|
7
|
+
// - game default profile: .forgeax/games/<slug>/.gen3d/playable-character-profile.json
|
|
8
|
+
// - character override + motion mapping: source character's own
|
|
9
|
+
// *.glb.gen3d-meta.json sidecar, under custom.playableOverride / custom.motionMapping
|
|
10
|
+
// (never the engine meta — MAP2)
|
|
11
|
+
|
|
12
|
+
export type PlaybackMode = 'loop' | 'once' | 'freeze_frame';
|
|
13
|
+
export type RootMotionStrategy = 'preserve' | 'remove_xz' | 'remove_xyz';
|
|
14
|
+
|
|
15
|
+
export interface MotionSlotDef {
|
|
16
|
+
// Stable, code-facing id (PROF4). Display name may change freely.
|
|
17
|
+
slotId: string;
|
|
18
|
+
displayName: string;
|
|
19
|
+
required: boolean;
|
|
20
|
+
playbackMode: PlaybackMode;
|
|
21
|
+
speed: number;
|
|
22
|
+
matchKeywords: string[];
|
|
23
|
+
rootMotion: RootMotionStrategy;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface GameMotionProfile {
|
|
27
|
+
schemaVersion: 1;
|
|
28
|
+
profileId: string;
|
|
29
|
+
// Bumped on every structural save (PROF3 migration-review trigger for R10).
|
|
30
|
+
profileVersion: number;
|
|
31
|
+
displayName: string;
|
|
32
|
+
slots: MotionSlotDef[];
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CharacterMotionOverride {
|
|
37
|
+
schemaVersion: 1;
|
|
38
|
+
// Full effective slot list for this character (PROF6: scoped to just this
|
|
39
|
+
// character, so this REPLACES the game default wholesale — never merged by
|
|
40
|
+
// slotId — else a slot the user deliberately dropped for this character
|
|
41
|
+
// would resurrect from the game default).
|
|
42
|
+
slots: MotionSlotDef[];
|
|
43
|
+
basedOnProfileId: string;
|
|
44
|
+
basedOnProfileVersion: number;
|
|
45
|
+
updatedAt: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MotionMappingEntry {
|
|
49
|
+
slotId: string;
|
|
50
|
+
// motionRefKey() of the source clip filling this slot; null = unmapped.
|
|
51
|
+
motionRefKey: string | null;
|
|
52
|
+
// True until the user has reviewed/confirmed an auto-suggested mapping (MAP1).
|
|
53
|
+
autoMatched: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MotionMappingDraft {
|
|
57
|
+
schemaVersion: 1;
|
|
58
|
+
mappings: MotionMappingEntry[];
|
|
59
|
+
confirmed: boolean;
|
|
60
|
+
updatedAt: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ProfilePreset {
|
|
64
|
+
profileId: string;
|
|
65
|
+
displayName: string;
|
|
66
|
+
slots: MotionSlotDef[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function slot(
|
|
70
|
+
slotId: string,
|
|
71
|
+
displayName: string,
|
|
72
|
+
required: boolean,
|
|
73
|
+
rootMotion: RootMotionStrategy,
|
|
74
|
+
): MotionSlotDef {
|
|
75
|
+
return {
|
|
76
|
+
slotId,
|
|
77
|
+
displayName,
|
|
78
|
+
required,
|
|
79
|
+
playbackMode: 'loop',
|
|
80
|
+
speed: 1,
|
|
81
|
+
matchKeywords: [slotId],
|
|
82
|
+
rootMotion,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// PROF2: four built-in presets. blank-custom-v1 starts with zero slots; PROF5
|
|
87
|
+
// requires the user add >=1 required slot before export (enforced at export
|
|
88
|
+
// time, not here).
|
|
89
|
+
export const BUILTIN_PROFILE_PRESETS: readonly ProfilePreset[] = [
|
|
90
|
+
{
|
|
91
|
+
profileId: 'basic-character-v1',
|
|
92
|
+
displayName: '基础角色',
|
|
93
|
+
slots: [slot('idle', '待机', true, 'preserve'), slot('move', '移动', true, 'remove_xz')],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
profileId: 'action-adventure-v1',
|
|
97
|
+
displayName: '动作冒险',
|
|
98
|
+
slots: [
|
|
99
|
+
slot('idle', '待机', true, 'preserve'),
|
|
100
|
+
slot('move', '移动', true, 'remove_xz'),
|
|
101
|
+
slot('attack', '攻击', true, 'preserve'),
|
|
102
|
+
slot('hit', '受击', false, 'preserve'),
|
|
103
|
+
slot('death', '死亡', false, 'preserve'),
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
profileId: 'platformer-v1',
|
|
108
|
+
displayName: '平台跳跃',
|
|
109
|
+
slots: [
|
|
110
|
+
slot('idle', '待机', true, 'preserve'),
|
|
111
|
+
slot('move', '移动', true, 'remove_xz'),
|
|
112
|
+
slot('jump', '跳跃', true, 'remove_xz'),
|
|
113
|
+
slot('fall', '下落', false, 'preserve'),
|
|
114
|
+
slot('land', '落地', false, 'preserve'),
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
profileId: 'blank-custom-v1',
|
|
119
|
+
displayName: '空白自定义',
|
|
120
|
+
slots: [],
|
|
121
|
+
},
|
|
122
|
+
] as const;
|
|
123
|
+
|
|
124
|
+
export function findPreset(profileId: string): ProfilePreset | null {
|
|
125
|
+
return BUILTIN_PROFILE_PRESETS.find((p) => p.profileId === profileId) ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function gameProfileFromPreset(profileId: string, now: string): GameMotionProfile {
|
|
129
|
+
const preset = findPreset(profileId) ?? BUILTIN_PROFILE_PRESETS[0];
|
|
130
|
+
return {
|
|
131
|
+
schemaVersion: 1,
|
|
132
|
+
profileId: preset.profileId,
|
|
133
|
+
profileVersion: 1,
|
|
134
|
+
displayName: preset.displayName,
|
|
135
|
+
slots: preset.slots.map((s) => ({ ...s, matchKeywords: [...s.matchKeywords] })),
|
|
136
|
+
updatedAt: now,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Effective profile = game default (+) character override (PLAN §5.5). An
|
|
141
|
+
// override present REPLACES the slot list wholesale (see CharacterMotionOverride
|
|
142
|
+
// doc) rather than merging by slotId.
|
|
143
|
+
export function effectiveSlots(
|
|
144
|
+
gameProfile: GameMotionProfile,
|
|
145
|
+
override: CharacterMotionOverride | null,
|
|
146
|
+
): MotionSlotDef[] {
|
|
147
|
+
return override ? override.slots : gameProfile.slots;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface MotionCandidate {
|
|
151
|
+
motionRefKey: string;
|
|
152
|
+
label: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// MAP1: suggest a slot -> motion mapping by case-insensitive substring match of
|
|
156
|
+
// a slot's keywords against each candidate's label. First unclaimed candidate
|
|
157
|
+
// wins per slot, in slot order. MAP3 permits the same candidate to be suggested
|
|
158
|
+
// for more than one slot (the user confirms/adjusts before saving).
|
|
159
|
+
export function autoMatchMotionMapping(
|
|
160
|
+
slots: readonly MotionSlotDef[],
|
|
161
|
+
candidates: readonly MotionCandidate[],
|
|
162
|
+
): MotionMappingEntry[] {
|
|
163
|
+
return slots.map((s) => {
|
|
164
|
+
const keywords = s.matchKeywords.map((k) => k.toLowerCase()).filter(Boolean);
|
|
165
|
+
const match = candidates.find((c) => {
|
|
166
|
+
const label = c.label.toLowerCase();
|
|
167
|
+
return keywords.some((k) => label.includes(k));
|
|
168
|
+
});
|
|
169
|
+
return { slotId: s.slotId, motionRefKey: match?.motionRefKey ?? null, autoMatched: true };
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
applyRodinPromptFlags,
|
|
4
|
+
filterProviderParams,
|
|
5
|
+
providerParamSpec,
|
|
6
|
+
rodinSubdivisionLevelHelp,
|
|
7
|
+
RODIN_IMAGE_FLAG_STUB,
|
|
8
|
+
RODIN_PROMPT_CHAR_LIMIT,
|
|
9
|
+
} from './provider-params';
|
|
10
|
+
|
|
11
|
+
test('meshy text: keeps verified+applicable fields, drops unknown + deprecated', () => {
|
|
12
|
+
const out = filterProviderParams('meshy', 'text', {
|
|
13
|
+
ai_model: 'meshy-6',
|
|
14
|
+
topology: 'quad',
|
|
15
|
+
art_style: 'realistic', // deprecated → not in spec → dropped
|
|
16
|
+
bogus: 'x', // unknown → dropped
|
|
17
|
+
});
|
|
18
|
+
expect(out).toEqual({ ai_model: 'meshy-6', topology: 'quad' });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('meshy model_type only applies to image mode', () => {
|
|
22
|
+
expect(filterProviderParams('meshy', 'text', { model_type: 'lowpoly' })).toEqual({});
|
|
23
|
+
expect(filterProviderParams('meshy', 'image', { model_type: 'lowpoly' })).toEqual({
|
|
24
|
+
model_type: 'lowpoly',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('meshy image: smart-topology + t2 + hd_texture pass filter', () => {
|
|
29
|
+
expect(
|
|
30
|
+
filterProviderParams('meshy', 'image', {
|
|
31
|
+
model_type: 'smart-topology',
|
|
32
|
+
ai_model: 'meshy-t2',
|
|
33
|
+
hd_texture: true,
|
|
34
|
+
}),
|
|
35
|
+
).toEqual({
|
|
36
|
+
model_type: 'smart-topology',
|
|
37
|
+
ai_model: 'meshy-t2',
|
|
38
|
+
hd_texture: true,
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('meshy P1 texture knobs pass filter for text/image', () => {
|
|
43
|
+
const knobs = {
|
|
44
|
+
texture_prompt: 'leather armor',
|
|
45
|
+
texture_image_url: 'https://example.com/tex.png',
|
|
46
|
+
remove_lighting: true,
|
|
47
|
+
};
|
|
48
|
+
expect(filterProviderParams('meshy', 'text', knobs)).toEqual(knobs);
|
|
49
|
+
expect(filterProviderParams('meshy', 'image', knobs)).toEqual(knobs);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('enum rejects invalid values; bool rejects non-bool', () => {
|
|
53
|
+
expect(filterProviderParams('meshy', 'text', { ai_model: 'meshy-9' })).toEqual({});
|
|
54
|
+
expect(filterProviderParams('meshy', 'text', { should_remesh: 'yes' as unknown as boolean })).toEqual(
|
|
55
|
+
{},
|
|
56
|
+
);
|
|
57
|
+
expect(filterProviderParams('meshy', 'text', { should_remesh: true })).toEqual({
|
|
58
|
+
should_remesh: true,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('rodin keeps official flag keys; drops old Hyper3D JSON knobs and quality_override', () => {
|
|
63
|
+
expect(providerParamSpec.rodin.some((f) => f.key === 'mesh_mode')).toBe(true);
|
|
64
|
+
expect(providerParamSpec.rodin.some((f) => f.key === 'quality_override')).toBe(false);
|
|
65
|
+
expect(filterProviderParams('rodin', 'text', { quality: 'high', tier: 'Gen-2', quality_override: 18000 })).toEqual(
|
|
66
|
+
{},
|
|
67
|
+
);
|
|
68
|
+
expect(
|
|
69
|
+
filterProviderParams('rodin', 'image', {
|
|
70
|
+
mesh_mode: 'Quad',
|
|
71
|
+
subdivisionlevel: 'medium',
|
|
72
|
+
material: 'PBR',
|
|
73
|
+
TAPose: true,
|
|
74
|
+
hd_texture: true,
|
|
75
|
+
addons: 'HighPack',
|
|
76
|
+
use_original_alpha: true,
|
|
77
|
+
}),
|
|
78
|
+
).toEqual({
|
|
79
|
+
mesh_mode: 'Quad',
|
|
80
|
+
subdivisionlevel: 'medium',
|
|
81
|
+
material: 'PBR',
|
|
82
|
+
TAPose: true,
|
|
83
|
+
hd_texture: true,
|
|
84
|
+
addons: true,
|
|
85
|
+
use_original_alpha: true,
|
|
86
|
+
});
|
|
87
|
+
expect(filterProviderParams('rodin', 'text', { use_original_alpha: true })).toEqual({});
|
|
88
|
+
expect(filterProviderParams('rodin', 'views', { mesh_mode: 'Raw', use_original_alpha: true })).toEqual({
|
|
89
|
+
mesh_mode: 'Raw',
|
|
90
|
+
use_original_alpha: true,
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('rodinSubdivisionLevelHelp: short chips; numbers only after mesh', () => {
|
|
95
|
+
expect(providerParamSpec.rodin.find((f) => f.key === 'subdivisionlevel')?.options?.map((o) => o.label)).toEqual([
|
|
96
|
+
'高',
|
|
97
|
+
'中',
|
|
98
|
+
'低',
|
|
99
|
+
]);
|
|
100
|
+
expect(rodinSubdivisionLevelHelp()).toMatch(/一般选中/);
|
|
101
|
+
expect(rodinSubdivisionLevelHelp('Quad')).toBe('大约 5万 / 1.8万 / 8千 面');
|
|
102
|
+
expect(rodinSubdivisionLevelHelp('Raw')).toBe('大约 50万 / 15万 / 2万 面');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('applyRodinPromptFlags: unset omitted; image stub; sl not override; trim text', () => {
|
|
106
|
+
expect(applyRodinPromptFlags('a red fox', {}, 'text')).toBe('a red fox');
|
|
107
|
+
expect(applyRodinPromptFlags('a red fox', { mesh_mode: 'Quad', material: 'PBR' }, 'text')).toBe(
|
|
108
|
+
'a red fox --mesh_mode Quad --material PBR',
|
|
109
|
+
);
|
|
110
|
+
expect(applyRodinPromptFlags('', { mesh_mode: 'Raw' }, 'image')).toBe(
|
|
111
|
+
`${RODIN_IMAGE_FLAG_STUB} --mesh_mode Raw`,
|
|
112
|
+
);
|
|
113
|
+
expect(applyRodinPromptFlags('', {}, 'image')).toBe('');
|
|
114
|
+
expect(applyRodinPromptFlags('', { mesh_mode: 'Quad' }, 'views')).toBe(
|
|
115
|
+
`${RODIN_IMAGE_FLAG_STUB} --mesh_mode Quad`,
|
|
116
|
+
);
|
|
117
|
+
expect(applyRodinPromptFlags('fox', { subdivisionlevel: 'high' }, 'text')).toBe('fox --sl high');
|
|
118
|
+
expect(applyRodinPromptFlags('fox', { quality_override: 18000 }, 'text')).toBe('fox');
|
|
119
|
+
expect(applyRodinPromptFlags('fox', { TAPose: false, hd_texture: false }, 'text')).toBe('fox');
|
|
120
|
+
const long = 'x'.repeat(RODIN_PROMPT_CHAR_LIMIT);
|
|
121
|
+
const out = applyRodinPromptFlags(long, { mesh_mode: 'Quad' }, 'text');
|
|
122
|
+
expect(out.endsWith('--mesh_mode Quad')).toBe(true);
|
|
123
|
+
expect(out.length).toBeLessThanOrEqual(RODIN_PROMPT_CHAR_LIMIT);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('hunyuan_workflow has no advanced params (empty spec → {})', () => {
|
|
127
|
+
expect(providerParamSpec.hunyuan_workflow).toEqual([]);
|
|
128
|
+
expect(filterProviderParams('hunyuan_workflow', 'text', { anything: 1 })).toEqual({});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('undefined raw → {}', () => {
|
|
132
|
+
expect(filterProviderParams('meshy', 'text', undefined)).toEqual({});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('visvise quality enum; mid is image-only; low is disabled', () => {
|
|
136
|
+
expect(filterProviderParams('visvise', 'text', { quality: 'standard' })).toEqual({ quality: 'standard' });
|
|
137
|
+
expect(filterProviderParams('visvise', 'text', { quality: 'low' })).toEqual({});
|
|
138
|
+
expect(filterProviderParams('visvise', 'text', { quality: 'high' })).toEqual({});
|
|
139
|
+
expect(filterProviderParams('visvise', 'image', { quality: 'mid' })).toEqual({ quality: 'mid' });
|
|
140
|
+
expect(filterProviderParams('visvise', 'views', { quality: 'standard' })).toEqual({});
|
|
141
|
+
expect(providerParamSpec.visvise.find((f) => f.key === 'quality')?.options?.find((o) => o.value === 'low')?.disabled).toBe(
|
|
142
|
+
true,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('visvise extras pass filter; mid drops face_num and enable_pbr', () => {
|
|
147
|
+
expect(
|
|
148
|
+
filterProviderParams('visvise', 'image', {
|
|
149
|
+
quality: 'standard',
|
|
150
|
+
algorithm_model: ' hunyuan3D-3.5-0515 ',
|
|
151
|
+
enable_a_pose: true,
|
|
152
|
+
face_type: '2',
|
|
153
|
+
face_num: 20000,
|
|
154
|
+
enable_pbr: true,
|
|
155
|
+
}),
|
|
156
|
+
).toEqual({
|
|
157
|
+
quality: 'standard',
|
|
158
|
+
algorithm_model: 'hunyuan3D-3.5-0515',
|
|
159
|
+
enable_a_pose: true,
|
|
160
|
+
face_type: '2',
|
|
161
|
+
face_num: 20000,
|
|
162
|
+
enable_pbr: true,
|
|
163
|
+
});
|
|
164
|
+
expect(
|
|
165
|
+
filterProviderParams('visvise', 'image', {
|
|
166
|
+
quality: 'mid',
|
|
167
|
+
face_num: 20000,
|
|
168
|
+
enable_pbr: true,
|
|
169
|
+
enable_a_pose: true,
|
|
170
|
+
}),
|
|
171
|
+
).toEqual({ quality: 'mid', enable_a_pose: true });
|
|
172
|
+
});
|