talking-head-studio 0.4.10 → 0.4.12
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/README.md +299 -337
- package/dist/TalkingHead.d.ts +44 -28
- package/dist/TalkingHead.js +21 -2
- package/dist/TalkingHead.web.d.ts +37 -4
- package/dist/TalkingHead.web.js +28 -8
- package/dist/TalkingHeadVisualization.d.ts +22 -0
- package/dist/TalkingHeadVisualization.js +30 -10
- package/dist/api/studioApi.d.ts +12 -1
- package/dist/api/studioApi.js +41 -28
- package/dist/appearance/apply.js +2 -3
- package/dist/appearance/matchers.js +1 -2
- package/dist/appearance/schema.js +1 -2
- package/dist/contract.d.ts +14 -0
- package/dist/contract.js +30 -0
- package/dist/core/avatar/avatarCapabilities.d.ts +60 -0
- package/dist/core/avatar/avatarCapabilities.js +100 -0
- package/dist/core/avatar/backend.d.ts +130 -0
- package/dist/core/avatar/backend.js +4 -0
- package/dist/core/avatar/backends/gaussian.d.ts +49 -0
- package/dist/core/avatar/backends/gaussian.js +293 -0
- package/dist/core/avatar/backends/index.d.ts +3 -0
- package/dist/core/avatar/backends/index.js +7 -0
- package/dist/core/avatar/backends/morphTarget.d.ts +39 -0
- package/dist/core/avatar/backends/morphTarget.js +179 -0
- package/dist/core/avatar/faceControls.d.ts +40 -0
- package/dist/core/avatar/faceControls.js +138 -0
- package/dist/core/avatar/motion.d.ts +1713 -0
- package/dist/core/avatar/motion.js +550 -0
- package/dist/core/avatar/motionRuntime.d.ts +46 -0
- package/dist/core/avatar/motionRuntime.js +84 -0
- package/dist/core/avatar/schema.d.ts +78 -0
- package/dist/core/avatar/schema.js +134 -0
- package/dist/core/avatar/visemes.d.ts +47 -1
- package/dist/core/avatar/visemes.js +114 -1
- package/dist/editor/AvatarCanvas.js +93 -3
- package/dist/editor/AvatarEditor.native.js +19 -9
- package/dist/editor/AvatarModel.js +2 -2
- package/dist/editor/FaceSqueezeEditor.d.ts +3 -1
- package/dist/editor/FaceSqueezeEditor.js +195 -121
- package/dist/editor/FaceSqueezeEditor.web.d.ts +3 -1
- package/dist/editor/FaceSqueezeEditor.web.js +32 -30
- package/dist/editor/RigidAccessory.js +18 -4
- package/dist/editor/SkinnedClothing.js +19 -9
- package/dist/editor/boneLockedDrag.d.ts +11 -0
- package/dist/editor/boneLockedDrag.js +68 -0
- package/dist/editor/boneSnap.js +22 -12
- package/dist/editor/boneSnap.web.d.ts +27 -0
- package/dist/editor/boneSnap.web.js +99 -0
- package/dist/editor/index.web.d.ts +10 -0
- package/dist/editor/index.web.js +26 -0
- package/dist/editor/sounds/haha.wav +0 -0
- package/dist/editor/sounds/owie.wav +0 -0
- package/dist/editor/sounds/stop.wav +0 -0
- package/dist/editor/studioTheme.d.ts +14 -14
- package/dist/editor/studioTheme.js +19 -16
- package/dist/editor/types.d.ts +1 -0
- package/dist/html/accessories.d.ts +7 -0
- package/dist/html/accessories.js +149 -0
- package/dist/html/motion.d.ts +1 -0
- package/dist/html/motion.js +189 -0
- package/dist/html/visemes.d.ts +7 -0
- package/dist/html/visemes.js +348 -0
- package/dist/html.d.ts +1 -1
- package/dist/html.js +56 -734
- package/dist/index.d.ts +19 -1
- package/dist/index.js +44 -5
- package/dist/index.web.d.ts +18 -1
- package/dist/index.web.js +36 -3
- package/dist/platform/api/types.d.ts +10 -0
- package/dist/platform/api/types.js +2 -0
- package/dist/platform/marketplace/types.d.ts +32 -0
- package/dist/platform/marketplace/types.js +2 -0
- package/dist/platform/sdk/unity.d.ts +27 -0
- package/dist/platform/sdk/unity.js +2 -0
- package/dist/platform/sdk/unreal.d.ts +23 -0
- package/dist/platform/sdk/unreal.js +2 -0
- package/dist/platform/sdk/web.d.ts +16 -0
- package/dist/platform/sdk/web.js +2 -0
- package/dist/sketchfab/api.js +5 -5
- package/dist/sketchfab/glbInspect.d.ts +22 -0
- package/dist/sketchfab/glbInspect.js +58 -0
- package/dist/sketchfab/index.d.ts +3 -0
- package/dist/sketchfab/index.js +8 -1
- package/dist/sketchfab/inspectRemote.d.ts +13 -0
- package/dist/sketchfab/inspectRemote.js +77 -0
- package/dist/sketchfab/types.d.ts +10 -0
- package/dist/sketchfab/useSketchfabSearch.js +1 -2
- package/dist/studio/AccessoryBrowserScreen.d.ts +6 -0
- package/dist/studio/AccessoryBrowserScreen.js +626 -0
- package/dist/studio/AccessoryPanel.d.ts +10 -0
- package/dist/studio/AccessoryPanel.js +396 -0
- package/dist/studio/AppearancePanel.d.ts +9 -0
- package/dist/studio/AppearancePanel.js +77 -0
- package/dist/studio/AvatarCreatorScreen.d.ts +5 -0
- package/dist/studio/AvatarCreatorScreen.js +806 -0
- package/dist/studio/AvatarEditorScreen.d.ts +14 -0
- package/dist/studio/AvatarEditorScreen.js +510 -0
- package/dist/studio/AvatarGrid.d.ts +23 -0
- package/dist/studio/AvatarGrid.js +257 -0
- package/dist/studio/ColorSwatch.d.ts +8 -0
- package/dist/studio/ColorSwatch.js +100 -0
- package/dist/studio/CreateVoiceProfileSheet.d.ts +8 -0
- package/dist/studio/CreateVoiceProfileSheet.js +242 -0
- package/dist/studio/DetailsPanel.d.ts +15 -0
- package/dist/studio/DetailsPanel.js +239 -0
- package/dist/studio/FilamentEditor.d.ts +2 -0
- package/dist/studio/FilamentEditor.js +6 -0
- package/dist/studio/PrecisionPanel.d.ts +2 -0
- package/dist/studio/PrecisionPanel.js +7 -0
- package/dist/studio/PublicGalleryScreen.d.ts +5 -0
- package/dist/studio/PublicGalleryScreen.js +358 -0
- package/dist/studio/SketchfabModelCard.d.ts +20 -0
- package/dist/studio/SketchfabModelCard.js +104 -0
- package/dist/studio/StudioBrowseHeader.d.ts +9 -0
- package/dist/studio/StudioBrowseHeader.js +28 -0
- package/dist/studio/StudioEmptyState.d.ts +8 -0
- package/dist/studio/StudioEmptyState.js +29 -0
- package/dist/studio/StudioFloatingAction.d.ts +13 -0
- package/dist/studio/StudioFloatingAction.js +42 -0
- package/dist/studio/StudioSectionHeader.d.ts +7 -0
- package/dist/studio/StudioSectionHeader.js +27 -0
- package/dist/studio/StudioSurfaceCard.d.ts +8 -0
- package/dist/studio/StudioSurfaceCard.js +20 -0
- package/dist/studio/VoicePanel.d.ts +15 -0
- package/dist/studio/VoicePanel.js +305 -0
- package/dist/studio/constants.d.ts +3 -0
- package/dist/studio/constants.js +6 -0
- package/dist/studio/index.d.ts +29 -0
- package/dist/studio/index.js +54 -0
- package/dist/studio/useSketchfabCapabilities.d.ts +31 -0
- package/dist/studio/useSketchfabCapabilities.js +82 -0
- package/dist/tts/useDirectVisemeStream.d.ts +2 -6
- package/dist/tts/useDirectVisemeStream.js +16 -12
- package/dist/tts/useMotionMarkers.d.ts +0 -1
- package/dist/tts/useMotionMarkers.js +1 -2
- package/dist/utils/avatarUtils.js +94 -8
- package/dist/utils/faceLandmarkerToShapeWeights.js +21 -14
- package/dist/voice/convertToWav.js +1 -2
- package/dist/voice/index.d.ts +3 -0
- package/dist/voice/index.js +6 -1
- package/dist/voice/useAudioPlayer.js +18 -6
- package/dist/voice/useAudioRecording.js +1 -2
- package/dist/voice/useFaceControls.d.ts +14 -0
- package/dist/voice/useFaceControls.js +81 -0
- package/dist/voice/useVoicePreview.d.ts +7 -0
- package/dist/voice/useVoicePreview.js +83 -0
- package/dist/wardrobe/index.d.ts +3 -0
- package/dist/wardrobe/index.js +8 -1
- package/dist/wardrobe/useAccessoryGestures.d.ts +20 -0
- package/dist/wardrobe/useAccessoryGestures.js +94 -0
- package/dist/wardrobe/useAvatarWardrobeHydration.js +9 -4
- package/dist/wardrobe/useStudioAvatar.d.ts +29 -0
- package/dist/wardrobe/useStudioAvatar.js +186 -0
- package/dist/wardrobe/wardrobeStore.d.ts +2 -0
- package/dist/wardrobe/wardrobeStore.js +12 -2
- package/dist/wgpu/R3FWebGpuCanvas.d.ts +15 -0
- package/dist/wgpu/R3FWebGpuCanvas.js +176 -0
- package/dist/wgpu/WgpuAvatar.d.ts +26 -2
- package/dist/wgpu/WgpuAvatar.js +313 -46
- package/dist/wgpu/accessoryDefaults.d.ts +12 -0
- package/dist/wgpu/accessoryDefaults.js +19 -0
- package/dist/wgpu/blobShim.d.ts +2 -0
- package/dist/wgpu/blobShim.js +191 -0
- package/dist/wgpu/index.d.ts +1 -0
- package/dist/wgpu/index.js +4 -1
- package/dist/wgpu/loadGLTFFromUri.d.ts +2 -0
- package/dist/wgpu/loadGLTFFromUri.js +75 -0
- package/dist/wgpu/morphTables.js +21 -10
- package/dist/wgpu/motionState.d.ts +20 -0
- package/dist/wgpu/motionState.js +31 -0
- package/dist/wgpu/patchThreeForRN.d.ts +28 -0
- package/dist/wgpu/patchThreeForRN.js +292 -0
- package/dist/wgpu/scenePlacement.d.ts +5 -0
- package/dist/wgpu/scenePlacement.js +50 -0
- package/dist/wgpu/useAuthedModelUri.js +22 -11
- package/dist/wgpu/useNativeGLTF.d.ts +7 -0
- package/dist/wgpu/useNativeGLTF.js +36 -0
- package/package.json +102 -32
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.AvatarCreatorScreen = AvatarCreatorScreen;
|
|
37
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
38
|
+
const react_1 = require("react");
|
|
39
|
+
const react_native_1 = require("react-native");
|
|
40
|
+
const flash_list_1 = require("@shopify/flash-list");
|
|
41
|
+
const expo_image_1 = require("expo-image");
|
|
42
|
+
const bottom_sheet_1 = __importStar(require("@gorhom/bottom-sheet"));
|
|
43
|
+
const Haptics = __importStar(require("expo-haptics"));
|
|
44
|
+
const LegacyFileSystem = __importStar(require("expo-file-system/legacy"));
|
|
45
|
+
const expo_blur_1 = require("expo-blur");
|
|
46
|
+
const vector_icons_1 = require("@expo/vector-icons");
|
|
47
|
+
const StudioBrowseHeader_1 = require("./StudioBrowseHeader");
|
|
48
|
+
const StudioEmptyState_1 = require("./StudioEmptyState");
|
|
49
|
+
const SketchfabModelCard_1 = require("./SketchfabModelCard");
|
|
50
|
+
const useSketchfabCapabilities_1 = require("./useSketchfabCapabilities");
|
|
51
|
+
const base64_js_1 = require("base64-js");
|
|
52
|
+
const api_1 = require("../api");
|
|
53
|
+
const constants_1 = require("./constants");
|
|
54
|
+
const editor_1 = require("../editor");
|
|
55
|
+
const sketchfab_1 = require("../sketchfab");
|
|
56
|
+
const avatarCapabilities_1 = require("../core/avatar/avatarCapabilities");
|
|
57
|
+
const COLORS = editor_1.studioTheme.colors;
|
|
58
|
+
const SKETCHFAB_API = 'https://api.sketchfab.com/v3';
|
|
59
|
+
const DEFAULT_QUERY = 'character humanoid avatar';
|
|
60
|
+
const FILTER_CHIPS = [
|
|
61
|
+
'character',
|
|
62
|
+
'anime',
|
|
63
|
+
'robot',
|
|
64
|
+
'fantasy',
|
|
65
|
+
'sci-fi',
|
|
66
|
+
'cartoon',
|
|
67
|
+
];
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Helpers
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
function pickThumbnail(thumbnails, targetWidth = 600) {
|
|
72
|
+
const images = thumbnails?.images;
|
|
73
|
+
if (!images || images.length === 0)
|
|
74
|
+
return null;
|
|
75
|
+
let best = images[0];
|
|
76
|
+
let bestDiff = Math.abs(best.width - targetWidth);
|
|
77
|
+
for (let i = 1; i < images.length; i++) {
|
|
78
|
+
const diff = Math.abs(images[i].width - targetWidth);
|
|
79
|
+
if (diff < bestDiff) {
|
|
80
|
+
best = images[i];
|
|
81
|
+
bestDiff = diff;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return best.url;
|
|
85
|
+
}
|
|
86
|
+
function isHumanoid(model) {
|
|
87
|
+
const HUMANOID_TAGS = [
|
|
88
|
+
'character', 'humanoid', 'human', 'anime', 'avatar',
|
|
89
|
+
'person', 'figure', 'girl', 'boy', 'woman', 'man',
|
|
90
|
+
];
|
|
91
|
+
return model.tags.some((t) => HUMANOID_TAGS.includes(t.slug.toLowerCase()));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read just the GLB JSON chunk from a downloaded file and report whether the
|
|
95
|
+
* model has morph targets. Returns null when the check can't run (bad header,
|
|
96
|
+
* filesystem error) so callers treat it as "unknown" rather than failing.
|
|
97
|
+
*/
|
|
98
|
+
async function glbFileCapabilities(fileUri) {
|
|
99
|
+
try {
|
|
100
|
+
const headerB64 = await LegacyFileSystem.readAsStringAsync(fileUri, {
|
|
101
|
+
encoding: LegacyFileSystem.EncodingType.Base64,
|
|
102
|
+
position: 0,
|
|
103
|
+
length: 20,
|
|
104
|
+
});
|
|
105
|
+
const jsonLength = (0, sketchfab_1.glbJsonChunkLength)((0, base64_js_1.toByteArray)(headerB64));
|
|
106
|
+
if (jsonLength === null || jsonLength <= 0)
|
|
107
|
+
return null;
|
|
108
|
+
const jsonB64 = await LegacyFileSystem.readAsStringAsync(fileUri, {
|
|
109
|
+
encoding: LegacyFileSystem.EncodingType.Base64,
|
|
110
|
+
position: 20,
|
|
111
|
+
length: jsonLength,
|
|
112
|
+
});
|
|
113
|
+
return (0, avatarCapabilities_1.inspectAvatarJsonChunk)((0, base64_js_1.toByteArray)(jsonB64));
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
console.warn('[Create] GLB capability check failed:', err);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Promise-wrapped confirm for an avatar that can't fully lip-sync and/or move.
|
|
122
|
+
* Soft-warn — the user can still import. Alert buttons are a no-op on RN-web,
|
|
123
|
+
* so that path falls back to window.confirm.
|
|
124
|
+
*/
|
|
125
|
+
function confirmLimitedAvatar(cap) {
|
|
126
|
+
const issues = [];
|
|
127
|
+
if (!cap.canLipSync) {
|
|
128
|
+
issues.push(cap.lipSyncTier === 'minimal'
|
|
129
|
+
? '• Only basic jaw movement — no facial blend shapes for real lipsync'
|
|
130
|
+
: '• No lipsync — no facial blend shapes');
|
|
131
|
+
}
|
|
132
|
+
if (!cap.canMove) {
|
|
133
|
+
issues.push('• No body gestures — the rig isn’t compatible with the motion engine');
|
|
134
|
+
}
|
|
135
|
+
const title = 'Limited Avatar';
|
|
136
|
+
const message = `This model has limitations:\n\n${issues.join('\n')}\n\nImport anyway?`;
|
|
137
|
+
if (react_native_1.Platform.OS === 'web') {
|
|
138
|
+
return Promise.resolve(typeof window !== 'undefined' ? window.confirm(`${title}\n\n${message}`) : true);
|
|
139
|
+
}
|
|
140
|
+
return new Promise((resolve) => {
|
|
141
|
+
react_native_1.Alert.alert(title, message, [
|
|
142
|
+
{ text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
|
|
143
|
+
{ text: 'Import Anyway', onPress: () => resolve(true) },
|
|
144
|
+
]);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function formatCount(n) {
|
|
148
|
+
if (n >= 1000000)
|
|
149
|
+
return `${(n / 1000000).toFixed(1)}M`;
|
|
150
|
+
if (n >= 1000)
|
|
151
|
+
return `${(n / 1000).toFixed(1)}k`;
|
|
152
|
+
return String(n);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Good-first rank for a search result (lower = higher in the list):
|
|
156
|
+
* 0 backend-verified · 1 lip-sync + motion · 2 lip-sync only ·
|
|
157
|
+
* 3 unknown/pending/uninspected · 4 inspected but can't lip-sync (sinks).
|
|
158
|
+
*/
|
|
159
|
+
function capabilityRank(uid, verifiedUids, capabilities) {
|
|
160
|
+
if (verifiedUids.has(uid))
|
|
161
|
+
return 0;
|
|
162
|
+
const entry = capabilities.get(uid);
|
|
163
|
+
if (entry?.status === 'done') {
|
|
164
|
+
const { canLipSync, canMove } = entry.cap;
|
|
165
|
+
if (canLipSync && canMove)
|
|
166
|
+
return 1;
|
|
167
|
+
if (canLipSync)
|
|
168
|
+
return 2;
|
|
169
|
+
return 4;
|
|
170
|
+
}
|
|
171
|
+
return 3;
|
|
172
|
+
}
|
|
173
|
+
/** Card/sheet badge for a result's pre-flight capability, or null for none. */
|
|
174
|
+
function capabilityBadge(uid, verifiedUids, capabilities) {
|
|
175
|
+
if (verifiedUids.has(uid))
|
|
176
|
+
return 'lipsync';
|
|
177
|
+
const entry = capabilities.get(uid);
|
|
178
|
+
if (!entry)
|
|
179
|
+
return null;
|
|
180
|
+
if (entry.status === 'pending')
|
|
181
|
+
return 'checking';
|
|
182
|
+
if (entry.status === 'unknown')
|
|
183
|
+
return null;
|
|
184
|
+
const { canLipSync, canMove } = entry.cap;
|
|
185
|
+
if (canLipSync && canMove)
|
|
186
|
+
return 'ready';
|
|
187
|
+
if (canLipSync)
|
|
188
|
+
return 'lipsync';
|
|
189
|
+
return 'limited';
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Backdrop component
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
const renderBackdrop = (props) => ((0, jsx_runtime_1.jsx)(bottom_sheet_1.BottomSheetBackdrop, { ...props, disappearsOnIndex: -1, appearsOnIndex: 0, pressBehavior: "close", opacity: 0.6 }));
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Main screen
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
function AvatarCreatorScreen({ sketchfabApiKey, onAvatarCreated, }) {
|
|
199
|
+
const bottomSheetRef = (0, react_1.useRef)(null);
|
|
200
|
+
// Responsive layout. The screen is mobile-first (fixed 2-col grid + bottom
|
|
201
|
+
// sheet). On web we widen the grid with available width and present the
|
|
202
|
+
// model detail as a centered modal instead of a phone-style bottom sheet.
|
|
203
|
+
const isWeb = react_native_1.Platform.OS === 'web';
|
|
204
|
+
const { width: windowWidth } = (0, react_native_1.useWindowDimensions)();
|
|
205
|
+
const numColumns = (0, react_1.useMemo)(() => {
|
|
206
|
+
if (!isWeb)
|
|
207
|
+
return 2;
|
|
208
|
+
// Cards read well at ~260px; cap at 5 columns so they don't get tiny.
|
|
209
|
+
return Math.max(2, Math.min(5, Math.floor(windowWidth / 260)));
|
|
210
|
+
}, [isWeb, windowWidth]);
|
|
211
|
+
// Search state
|
|
212
|
+
const [searchText, setSearchText] = (0, react_1.useState)('');
|
|
213
|
+
const [activeChip, setActiveChip] = (0, react_1.useState)(null);
|
|
214
|
+
const [query, setQuery] = (0, react_1.useState)(DEFAULT_QUERY);
|
|
215
|
+
// Data state
|
|
216
|
+
const [models, setModels] = (0, react_1.useState)([]);
|
|
217
|
+
const [loading, setLoading] = (0, react_1.useState)(true);
|
|
218
|
+
const [loadingMore, setLoadingMore] = (0, react_1.useState)(false);
|
|
219
|
+
const [error, setError] = (0, react_1.useState)(null);
|
|
220
|
+
const [nextCursor, setNextCursor] = (0, react_1.useState)(null);
|
|
221
|
+
// Sketchfab uids that previously imported with working morph targets.
|
|
222
|
+
// Best-effort: an empty set just means no pinning/badges.
|
|
223
|
+
const [verifiedUids, setVerifiedUids] = (0, react_1.useState)(new Set());
|
|
224
|
+
// Bottom sheet state
|
|
225
|
+
const [selected, setSelected] = (0, react_1.useState)(null);
|
|
226
|
+
// Import state
|
|
227
|
+
const [importing, setImporting] = (0, react_1.useState)(false);
|
|
228
|
+
const [importPhase, setImportPhase] = (0, react_1.useState)(null);
|
|
229
|
+
const [importProgress, setImportProgress] = (0, react_1.useState)(0);
|
|
230
|
+
// Debounce ref
|
|
231
|
+
const debounceRef = (0, react_1.useRef)(null);
|
|
232
|
+
// Bottom sheet snap points
|
|
233
|
+
const snapPoints = (0, react_1.useMemo)(() => ['55%', '80%'], []);
|
|
234
|
+
// ------ Sketchfab search ------------------------------------------------
|
|
235
|
+
const searchSketchfab = (0, react_1.useCallback)(async (q, cursor) => {
|
|
236
|
+
const effectiveQuery = q.trim() || DEFAULT_QUERY;
|
|
237
|
+
const params = new URLSearchParams({
|
|
238
|
+
type: 'models',
|
|
239
|
+
downloadable: 'true',
|
|
240
|
+
rigged: 'true',
|
|
241
|
+
sort_by: '-likeCount',
|
|
242
|
+
count: String(constants_1.RESULTS_PER_PAGE),
|
|
243
|
+
q: effectiveQuery,
|
|
244
|
+
});
|
|
245
|
+
if (cursor) {
|
|
246
|
+
params.set('cursor', cursor);
|
|
247
|
+
}
|
|
248
|
+
const res = await fetch(`${SKETCHFAB_API}/search?${params.toString()}`, {
|
|
249
|
+
headers: { Authorization: `Token ${sketchfabApiKey}` },
|
|
250
|
+
});
|
|
251
|
+
if (!res.ok) {
|
|
252
|
+
throw new Error(`Sketchfab API error: ${res.status}`);
|
|
253
|
+
}
|
|
254
|
+
return (await res.json());
|
|
255
|
+
}, [sketchfabApiKey]);
|
|
256
|
+
const loadInitial = (0, react_1.useCallback)(async (q) => {
|
|
257
|
+
try {
|
|
258
|
+
setLoading(true);
|
|
259
|
+
setError(null);
|
|
260
|
+
setModels([]);
|
|
261
|
+
setNextCursor(null);
|
|
262
|
+
const data = await searchSketchfab(q);
|
|
263
|
+
const filtered = data.results.filter(m => {
|
|
264
|
+
const size = m.archives?.glb?.size ?? m.archives?.gltf?.size;
|
|
265
|
+
return !size || size <= constants_1.MAX_ASSET_SIZE;
|
|
266
|
+
});
|
|
267
|
+
setModels(filtered);
|
|
268
|
+
setNextCursor(data.cursors?.next ?? null);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
setError(err instanceof Error ? err.message : 'Failed to search Sketchfab');
|
|
272
|
+
}
|
|
273
|
+
finally {
|
|
274
|
+
setLoading(false);
|
|
275
|
+
}
|
|
276
|
+
}, [searchSketchfab]);
|
|
277
|
+
const loadMore = (0, react_1.useCallback)(async () => {
|
|
278
|
+
if (!nextCursor || loadingMore)
|
|
279
|
+
return;
|
|
280
|
+
try {
|
|
281
|
+
setLoadingMore(true);
|
|
282
|
+
const data = await searchSketchfab(query, nextCursor);
|
|
283
|
+
const filtered = data.results.filter(m => {
|
|
284
|
+
const size = m.archives?.glb?.size ?? m.archives?.gltf?.size;
|
|
285
|
+
return !size || size <= constants_1.MAX_ASSET_SIZE;
|
|
286
|
+
});
|
|
287
|
+
setModels((prev) => [...prev, ...filtered]);
|
|
288
|
+
setNextCursor(data.cursors?.next ?? null);
|
|
289
|
+
}
|
|
290
|
+
catch (err) {
|
|
291
|
+
react_native_1.Alert.alert('Error', err instanceof Error ? err.message : 'Failed to load more results');
|
|
292
|
+
}
|
|
293
|
+
finally {
|
|
294
|
+
setLoadingMore(false);
|
|
295
|
+
}
|
|
296
|
+
}, [nextCursor, loadingMore, query, searchSketchfab]);
|
|
297
|
+
// Initial load
|
|
298
|
+
(0, react_1.useEffect)(() => {
|
|
299
|
+
void loadInitial(query);
|
|
300
|
+
}, [query, loadInitial]);
|
|
301
|
+
(0, react_1.useEffect)(() => {
|
|
302
|
+
(0, api_1.getVerifiedSketchfabUids)()
|
|
303
|
+
.then((uids) => setVerifiedUids(new Set(uids)))
|
|
304
|
+
.catch((err) => console.warn('[Create] Failed to load verified models:', err));
|
|
305
|
+
}, []);
|
|
306
|
+
// Background-inspect the leading results so we can float lip-sync + motion
|
|
307
|
+
// ready avatars to the top and badge them before the user commits to import.
|
|
308
|
+
const capabilities = (0, useSketchfabCapabilities_1.useSketchfabCapabilities)({
|
|
309
|
+
models,
|
|
310
|
+
apiKey: sketchfabApiKey,
|
|
311
|
+
});
|
|
312
|
+
// Good-first ordering: backend-verified + inspected-good first, weak last,
|
|
313
|
+
// preserving Sketchfab's search order within each rank (stable sort).
|
|
314
|
+
const sortedModels = (0, react_1.useMemo)(() => {
|
|
315
|
+
return models
|
|
316
|
+
.map((m, i) => ({ m, i, rank: capabilityRank(m.uid, verifiedUids, capabilities) }))
|
|
317
|
+
.sort((a, b) => a.rank - b.rank || a.i - b.i)
|
|
318
|
+
.map((x) => x.m);
|
|
319
|
+
}, [models, verifiedUids, capabilities]);
|
|
320
|
+
// ------ Debounced text search -------------------------------------------
|
|
321
|
+
const handleSearchChange = (0, react_1.useCallback)((text) => {
|
|
322
|
+
setSearchText(text);
|
|
323
|
+
setActiveChip(null);
|
|
324
|
+
if (debounceRef.current) {
|
|
325
|
+
clearTimeout(debounceRef.current);
|
|
326
|
+
}
|
|
327
|
+
debounceRef.current = setTimeout(() => {
|
|
328
|
+
const trimmed = text.trim();
|
|
329
|
+
const nextQuery = trimmed ? `${trimmed} 3d model character humanoid avatar` : DEFAULT_QUERY;
|
|
330
|
+
setQuery(nextQuery);
|
|
331
|
+
}, 400);
|
|
332
|
+
}, []);
|
|
333
|
+
(0, react_1.useEffect)(() => {
|
|
334
|
+
return () => {
|
|
335
|
+
if (debounceRef.current)
|
|
336
|
+
clearTimeout(debounceRef.current);
|
|
337
|
+
};
|
|
338
|
+
}, []);
|
|
339
|
+
// ------ Filter chips ----------------------------------------------------
|
|
340
|
+
const handleChipPress = (0, react_1.useCallback)((chip) => {
|
|
341
|
+
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
342
|
+
if (activeChip === chip) {
|
|
343
|
+
setActiveChip(null);
|
|
344
|
+
setSearchText('');
|
|
345
|
+
setQuery(DEFAULT_QUERY);
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
setActiveChip(chip);
|
|
349
|
+
setSearchText(chip);
|
|
350
|
+
setQuery(chip);
|
|
351
|
+
}
|
|
352
|
+
if (debounceRef.current) {
|
|
353
|
+
clearTimeout(debounceRef.current);
|
|
354
|
+
debounceRef.current = null;
|
|
355
|
+
}
|
|
356
|
+
}, [activeChip]);
|
|
357
|
+
// ------ Card tap --------------------------------------------------------
|
|
358
|
+
const handleCardPress = (0, react_1.useCallback)((model) => {
|
|
359
|
+
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
360
|
+
setSelected(model);
|
|
361
|
+
setImportPhase(null);
|
|
362
|
+
setImportProgress(0);
|
|
363
|
+
if (!isWeb)
|
|
364
|
+
bottomSheetRef.current?.snapToIndex(0);
|
|
365
|
+
}, [isWeb]);
|
|
366
|
+
const closeDetail = (0, react_1.useCallback)(() => {
|
|
367
|
+
setSelected(null);
|
|
368
|
+
setImportPhase(null);
|
|
369
|
+
setImportProgress(0);
|
|
370
|
+
}, []);
|
|
371
|
+
const handleSheetChange = (0, react_1.useCallback)((index) => {
|
|
372
|
+
if (index === -1)
|
|
373
|
+
closeDetail();
|
|
374
|
+
}, [closeDetail]);
|
|
375
|
+
// ------ Import flow -----------------------------------------------------
|
|
376
|
+
const handleImport = (0, react_1.useCallback)(async (model) => {
|
|
377
|
+
if (importing)
|
|
378
|
+
return;
|
|
379
|
+
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
|
380
|
+
try {
|
|
381
|
+
setImporting(true);
|
|
382
|
+
setImportPhase('downloading');
|
|
383
|
+
setImportProgress(0);
|
|
384
|
+
// 1. Get signed download URL
|
|
385
|
+
const downloadRes = await fetch(`${SKETCHFAB_API}/models/${model.uid}/download`, {
|
|
386
|
+
headers: { Authorization: `Token ${sketchfabApiKey}` },
|
|
387
|
+
});
|
|
388
|
+
if (!downloadRes.ok) {
|
|
389
|
+
throw new Error('Failed to get download URL');
|
|
390
|
+
}
|
|
391
|
+
const downloadData = await downloadRes.json();
|
|
392
|
+
// Check size before downloading (Max 50MB)
|
|
393
|
+
const MAX_SIZE = constants_1.MAX_ASSET_SIZE;
|
|
394
|
+
const skfSize = downloadData?.glb?.size ?? downloadData?.gltf?.size;
|
|
395
|
+
if (skfSize && skfSize > MAX_SIZE) {
|
|
396
|
+
const mb = (skfSize / (1024 * 1024)).toFixed(1);
|
|
397
|
+
throw new Error(`Model is too large (${mb}MB). Maximum size is 50MB.`);
|
|
398
|
+
}
|
|
399
|
+
const glbUrl = downloadData?.glb?.url ?? downloadData?.gltf?.url;
|
|
400
|
+
if (!glbUrl)
|
|
401
|
+
throw new Error('No download URL found');
|
|
402
|
+
// HEAD request to verify content-length (fallback)
|
|
403
|
+
let tooLargeError = null;
|
|
404
|
+
try {
|
|
405
|
+
const headRes = await fetch(glbUrl, { method: 'HEAD' });
|
|
406
|
+
const contentLength = headRes.headers.get('content-length');
|
|
407
|
+
if (contentLength && parseInt(contentLength, 10) > MAX_SIZE) {
|
|
408
|
+
const mb = (parseInt(contentLength, 10) / (1024 * 1024)).toFixed(1);
|
|
409
|
+
tooLargeError = new Error(`Model is too large (${mb}MB). Maximum size is 50MB.`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
catch (e) {
|
|
413
|
+
console.warn('[Create] HEAD size check failed:', e);
|
|
414
|
+
}
|
|
415
|
+
if (tooLargeError)
|
|
416
|
+
throw tooLargeError;
|
|
417
|
+
// 2. Download GLB to disk
|
|
418
|
+
const tmpPath = `${LegacyFileSystem.cacheDirectory}sketchfab_import.glb`;
|
|
419
|
+
const download = LegacyFileSystem.createDownloadResumable(glbUrl, tmpPath, {}, (progress) => {
|
|
420
|
+
const pct = progress.totalBytesWritten /
|
|
421
|
+
progress.totalBytesExpectedToWrite;
|
|
422
|
+
setImportProgress(pct);
|
|
423
|
+
});
|
|
424
|
+
const result = await download.downloadAsync();
|
|
425
|
+
if (!result)
|
|
426
|
+
throw new Error('Download failed');
|
|
427
|
+
// 3. Capability check — lipsync (facial blend shapes) + body motion
|
|
428
|
+
// (humanoid rig the procedural motion engine can drive). Soft-warn.
|
|
429
|
+
const cap = await glbFileCapabilities(result.uri);
|
|
430
|
+
if (cap && (!cap.canLipSync || !cap.canMove)) {
|
|
431
|
+
const proceed = await confirmLimitedAvatar(cap);
|
|
432
|
+
if (!proceed) {
|
|
433
|
+
await LegacyFileSystem.deleteAsync(tmpPath, { idempotent: true });
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
// 4. Upload to backend
|
|
438
|
+
setImportPhase('uploading');
|
|
439
|
+
setImportProgress(0);
|
|
440
|
+
const avatar = await (0, api_1.createAvatar)(result.uri, model.name, model.description || undefined, { sketchfabUid: model.uid, sketchfabHasMorphs: cap?.canLipSync === true });
|
|
441
|
+
// 5. Cleanup temp file
|
|
442
|
+
await LegacyFileSystem.deleteAsync(tmpPath, { idempotent: true });
|
|
443
|
+
// 6. Close sheet and navigate to editor
|
|
444
|
+
bottomSheetRef.current?.close();
|
|
445
|
+
onAvatarCreated?.(avatar.id);
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
react_native_1.Alert.alert('Import Failed', err instanceof Error ? err.message : 'Something went wrong during import.');
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
setImporting(false);
|
|
452
|
+
setImportPhase(null);
|
|
453
|
+
setImportProgress(0);
|
|
454
|
+
}
|
|
455
|
+
}, [importing, sketchfabApiKey, onAvatarCreated]);
|
|
456
|
+
// ------ Render helpers --------------------------------------------------
|
|
457
|
+
const renderItem = (0, react_1.useCallback)(({ item, index }) => ((0, jsx_runtime_1.jsx)(SketchfabModelCard_1.SketchfabModelCard, { model: item, index: index, onPress: handleCardPress, showHumanoidBadge: true, capability: capabilityBadge(item.uid, verifiedUids, capabilities) })), [handleCardPress, verifiedUids, capabilities]);
|
|
458
|
+
const keyExtractor = (0, react_1.useCallback)((item) => item.uid, []);
|
|
459
|
+
const renderFooter = (0, react_1.useCallback)(() => {
|
|
460
|
+
if (loadingMore) {
|
|
461
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.footerContainer, children: (0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { color: COLORS.accent, size: "small" }) }));
|
|
462
|
+
}
|
|
463
|
+
if (nextCursor) {
|
|
464
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.footerContainer, children: (0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { style: styles.loadMoreButton, activeOpacity: 0.7, onPress: loadMore, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadMoreText, children: "Load More" }) }) }));
|
|
465
|
+
}
|
|
466
|
+
return (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.gridBottomSpacer });
|
|
467
|
+
}, [loadingMore, nextCursor, loadMore]);
|
|
468
|
+
// ------ Import button label ---------------------------------------------
|
|
469
|
+
const importButtonLabel = (0, react_1.useMemo)(() => {
|
|
470
|
+
if (importPhase === 'downloading') {
|
|
471
|
+
return `Downloading\u2026 ${Math.round(importProgress * 100)}%`;
|
|
472
|
+
}
|
|
473
|
+
if (importPhase === 'uploading') {
|
|
474
|
+
return 'Uploading\u2026';
|
|
475
|
+
}
|
|
476
|
+
return 'Import Avatar';
|
|
477
|
+
}, [importPhase, importProgress]);
|
|
478
|
+
// ------ Selected model details ------------------------------------------
|
|
479
|
+
const selectedThumbnail = selected
|
|
480
|
+
? pickThumbnail(selected.thumbnails, 600)
|
|
481
|
+
: null;
|
|
482
|
+
const selectedBadge = selected
|
|
483
|
+
? capabilityBadge(selected.uid, verifiedUids, capabilities)
|
|
484
|
+
: null;
|
|
485
|
+
// ------ Header: search bar + chips --------------------------------------
|
|
486
|
+
const ListHeader = (0, react_1.useMemo)(() => ((0, jsx_runtime_1.jsxs)(StudioBrowseHeader_1.StudioBrowseHeader, { eyebrow: "Sketchfab", title: "Create from 3D models", subtitle: "Find a strong base character, then import it into the studio without breaking your rhythm.", children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.searchContainer, children: [(0, jsx_runtime_1.jsx)(vector_icons_1.Ionicons, { name: "search", size: 18, color: COLORS.textMuted, style: styles.searchIcon }), (0, jsx_runtime_1.jsx)(react_native_1.TextInput, { style: styles.searchInput, placeholder: "Search Sketchfab models...", placeholderTextColor: COLORS.textMuted, value: searchText, onChangeText: handleSearchChange, returnKeyType: "search", autoCorrect: false, autoCapitalize: "none" }), searchText.length > 0 && ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { onPress: () => handleSearchChange(''), hitSlop: { top: 8, bottom: 8, left: 8, right: 8 }, children: (0, jsx_runtime_1.jsx)(vector_icons_1.Ionicons, { name: "close-circle", size: 18, color: COLORS.textMuted }) }))] }), (0, jsx_runtime_1.jsx)(react_native_1.ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, contentContainerStyle: styles.chipScroll, children: FILTER_CHIPS.map((chip) => {
|
|
487
|
+
const isActive = activeChip === chip;
|
|
488
|
+
return ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { style: [styles.chip, isActive && styles.chipActive], activeOpacity: 0.7, onPress: () => handleChipPress(chip), children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
|
|
489
|
+
styles.chipText,
|
|
490
|
+
isActive && styles.chipTextActive,
|
|
491
|
+
], children: chip }) }, chip));
|
|
492
|
+
}) })] })), [searchText, activeChip, handleSearchChange, handleChipPress]);
|
|
493
|
+
// ------ Loading state ---------------------------------------------------
|
|
494
|
+
if (loading) {
|
|
495
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.container, children: [ListHeader, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.centered, children: [(0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { color: COLORS.accent, size: "large" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loadingText, children: "Searching models..." })] })] }));
|
|
496
|
+
}
|
|
497
|
+
// ------ Error state -----------------------------------------------------
|
|
498
|
+
if (error && models.length === 0) {
|
|
499
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.container, children: [ListHeader, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.centered, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.errorText, children: error }), (0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { onPress: () => loadInitial(query), style: styles.retryButton, activeOpacity: 0.7, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.retryButtonText, children: "Retry" }) })] })] }));
|
|
500
|
+
}
|
|
501
|
+
// ------ Empty state -----------------------------------------------------
|
|
502
|
+
if (!loading && models.length === 0) {
|
|
503
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.container, children: [ListHeader, (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.centered, children: (0, jsx_runtime_1.jsx)(StudioEmptyState_1.StudioEmptyState, { icon: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.emptyIcon, children: "SEARCH" }), title: "No models found", subtitle: "Try a different search term or filter." }) })] }));
|
|
504
|
+
}
|
|
505
|
+
// ------ Main grid -------------------------------------------------------
|
|
506
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
507
|
+
const AnyFlashList = flash_list_1.FlashList;
|
|
508
|
+
// Shared detail content — rendered inside a bottom sheet (native) or a
|
|
509
|
+
// centered modal (web). Identical body in both cases.
|
|
510
|
+
const detailBody = selected ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [selectedThumbnail ? ((0, jsx_runtime_1.jsx)(expo_image_1.Image, { source: { uri: selectedThumbnail }, style: styles.sheetThumbnail, contentFit: "cover", placeholder: { blurhash: constants_1.BLURHASH }, transition: 300 })) : ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.sheetThumbnailPlaceholder, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.sheetPlaceholderText, children: "3D" }) })), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.sheetName, children: selected.name }), selected.description ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.sheetDescription, numberOfLines: 4, children: selected.description })) : null, (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.statsRow, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.statItem, children: [(0, jsx_runtime_1.jsx)(vector_icons_1.Ionicons, { name: "heart", size: 14, color: COLORS.textSecondary }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.statText, children: formatCount(selected.likeCount) })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.statItem, children: [(0, jsx_runtime_1.jsx)(vector_icons_1.Ionicons, { name: "eye", size: 14, color: COLORS.textSecondary }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.statText, children: formatCount(selected.viewCount) })] })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.sheetBadgeRow, children: [selectedBadge === 'ready' && ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.sheetBadge, styles.sheetReadyBadge], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.sheetBadgeText, styles.sheetReadyText], children: "Lip-sync + motion" }) })), selectedBadge === 'lipsync' && ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.sheetBadge, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.sheetBadgeText, children: "Lip-sync ready" }) })), selectedBadge === 'limited' && ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.sheetBadge, styles.sheetLimitedBadge], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [styles.sheetBadgeText, styles.sheetLimitedText], children: "Limited lip-sync" }) })), isHumanoid(selected) && ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.sheetBadge, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.sheetBadgeText, children: "Humanoid" }) })), selected.animationCount > 0 && ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.sheetBadge, styles.sheetAnimatedBadge], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.sheetBadgeText, children: "Animated" }) }))] }), (0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.sheetMeta, children: ["by ", selected.user.displayName || selected.user.username, selected.license ? ` · ${selected.license.label}` : ''] }), importPhase === 'downloading' ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.progressContainer, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.progressTrack, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
511
|
+
styles.progressFill,
|
|
512
|
+
{ width: `${Math.round(importProgress * 100)}%` },
|
|
513
|
+
] }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.progressText, children: importButtonLabel })] })) : ((0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { style: [styles.importButton, importing && styles.importButtonDisabled], activeOpacity: 0.8, onPress: () => handleImport(selected), disabled: importing, children: importing ? ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.importButtonInner, children: [(0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { color: "#fff", size: "small" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.importButtonText, children: importButtonLabel })] })) : ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.importButtonText, children: "Import Avatar" })) }))] })) : null;
|
|
514
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.container, children: [(0, jsx_runtime_1.jsx)(AnyFlashList, { style: { flex: 1 }, data: sortedModels, keyExtractor: keyExtractor, renderItem: renderItem, numColumns: numColumns, estimatedItemSize: 200, contentContainerStyle: styles.gridContent, showsVerticalScrollIndicator: false, ListHeaderComponent: ListHeader, ListFooterComponent: renderFooter }), isWeb ? ((0, jsx_runtime_1.jsx)(react_native_1.Modal, { visible: !!selected, transparent: true, animationType: "fade", onRequestClose: closeDetail, children: (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.webModalBackdrop, onPress: closeDetail, children: (0, jsx_runtime_1.jsxs)(react_native_1.Pressable, { style: styles.webModalCard, onPress: () => { }, children: [(0, jsx_runtime_1.jsx)(react_native_1.TouchableOpacity, { style: styles.webModalClose, onPress: closeDetail, accessibilityLabel: "Close", children: (0, jsx_runtime_1.jsx)(vector_icons_1.Ionicons, { name: "close", size: 22, color: COLORS.textSecondary }) }), (0, jsx_runtime_1.jsx)(react_native_1.ScrollView, { contentContainerStyle: styles.sheetContent, children: selected ? detailBody : null })] }) }) })) : ((0, jsx_runtime_1.jsx)(bottom_sheet_1.default, { ref: bottomSheetRef, index: -1, snapPoints: snapPoints, enablePanDownToClose: true, onChange: handleSheetChange, backdropComponent: renderBackdrop, backgroundComponent: ((props) => ((0, jsx_runtime_1.jsx)(expo_blur_1.BlurView, { ...props, tint: "systemThickMaterialDark", intensity: 90, style: [
|
|
515
|
+
props.style,
|
|
516
|
+
styles.sheetBackground,
|
|
517
|
+
{ backgroundColor: 'rgba(15, 19, 26, 0.4)' },
|
|
518
|
+
] }))), handleIndicatorStyle: styles.sheetHandle, children: (0, jsx_runtime_1.jsx)(bottom_sheet_1.BottomSheetScrollView, { contentContainerStyle: styles.sheetContent, children: selected ? detailBody : null }) }))] }));
|
|
519
|
+
}
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// Styles
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
const styles = react_native_1.StyleSheet.create({
|
|
524
|
+
container: {
|
|
525
|
+
flex: 1,
|
|
526
|
+
backgroundColor: COLORS.background,
|
|
527
|
+
},
|
|
528
|
+
centered: {
|
|
529
|
+
flex: 1,
|
|
530
|
+
alignItems: 'center',
|
|
531
|
+
justifyContent: 'center',
|
|
532
|
+
paddingHorizontal: 32,
|
|
533
|
+
gap: 8,
|
|
534
|
+
},
|
|
535
|
+
searchContainer: {
|
|
536
|
+
flexDirection: 'row',
|
|
537
|
+
alignItems: 'center',
|
|
538
|
+
backgroundColor: COLORS.surface,
|
|
539
|
+
borderRadius: 14,
|
|
540
|
+
borderWidth: 1,
|
|
541
|
+
borderColor: COLORS.border,
|
|
542
|
+
marginHorizontal: 12,
|
|
543
|
+
paddingHorizontal: 14,
|
|
544
|
+
height: 46,
|
|
545
|
+
gap: 8,
|
|
546
|
+
},
|
|
547
|
+
searchIcon: {
|
|
548
|
+
marginRight: 2,
|
|
549
|
+
},
|
|
550
|
+
searchInput: {
|
|
551
|
+
flex: 1,
|
|
552
|
+
color: COLORS.textPrimary,
|
|
553
|
+
fontSize: 15,
|
|
554
|
+
paddingVertical: 0,
|
|
555
|
+
},
|
|
556
|
+
chipScroll: {
|
|
557
|
+
paddingHorizontal: 12,
|
|
558
|
+
gap: 8,
|
|
559
|
+
},
|
|
560
|
+
chip: {
|
|
561
|
+
paddingHorizontal: 14,
|
|
562
|
+
paddingVertical: 7,
|
|
563
|
+
borderRadius: 10,
|
|
564
|
+
backgroundColor: COLORS.surface,
|
|
565
|
+
borderWidth: 1,
|
|
566
|
+
borderColor: COLORS.border,
|
|
567
|
+
},
|
|
568
|
+
chipActive: {
|
|
569
|
+
backgroundColor: COLORS.accent,
|
|
570
|
+
borderColor: COLORS.accent,
|
|
571
|
+
},
|
|
572
|
+
chipText: {
|
|
573
|
+
color: COLORS.textSecondary,
|
|
574
|
+
fontSize: 13,
|
|
575
|
+
fontWeight: '500',
|
|
576
|
+
},
|
|
577
|
+
chipTextActive: {
|
|
578
|
+
color: '#fff',
|
|
579
|
+
fontWeight: '600',
|
|
580
|
+
},
|
|
581
|
+
gridContent: {
|
|
582
|
+
paddingHorizontal: 12,
|
|
583
|
+
},
|
|
584
|
+
gridBottomSpacer: {
|
|
585
|
+
height: 32,
|
|
586
|
+
},
|
|
587
|
+
footerContainer: {
|
|
588
|
+
paddingVertical: 20,
|
|
589
|
+
alignItems: 'center',
|
|
590
|
+
},
|
|
591
|
+
loadMoreButton: {
|
|
592
|
+
paddingHorizontal: 28,
|
|
593
|
+
paddingVertical: 12,
|
|
594
|
+
backgroundColor: COLORS.surface,
|
|
595
|
+
borderRadius: 12,
|
|
596
|
+
borderWidth: 1,
|
|
597
|
+
borderColor: COLORS.border,
|
|
598
|
+
},
|
|
599
|
+
loadMoreText: {
|
|
600
|
+
color: COLORS.textPrimary,
|
|
601
|
+
fontSize: 14,
|
|
602
|
+
fontWeight: '600',
|
|
603
|
+
},
|
|
604
|
+
loadingText: {
|
|
605
|
+
color: COLORS.textSecondary,
|
|
606
|
+
fontSize: 13,
|
|
607
|
+
marginTop: 8,
|
|
608
|
+
},
|
|
609
|
+
errorText: {
|
|
610
|
+
color: COLORS.danger,
|
|
611
|
+
fontSize: 14,
|
|
612
|
+
fontWeight: '500',
|
|
613
|
+
textAlign: 'center',
|
|
614
|
+
marginBottom: 8,
|
|
615
|
+
},
|
|
616
|
+
retryButton: {
|
|
617
|
+
paddingHorizontal: 24,
|
|
618
|
+
paddingVertical: 10,
|
|
619
|
+
backgroundColor: COLORS.surfaceSoft,
|
|
620
|
+
borderRadius: 10,
|
|
621
|
+
borderWidth: 1,
|
|
622
|
+
borderColor: COLORS.border,
|
|
623
|
+
},
|
|
624
|
+
retryButtonText: {
|
|
625
|
+
color: COLORS.textPrimary,
|
|
626
|
+
fontSize: 13,
|
|
627
|
+
fontWeight: '600',
|
|
628
|
+
},
|
|
629
|
+
emptyIcon: {
|
|
630
|
+
fontSize: 40,
|
|
631
|
+
marginBottom: 4,
|
|
632
|
+
},
|
|
633
|
+
sheetBackground: {
|
|
634
|
+
backgroundColor: COLORS.surface,
|
|
635
|
+
borderTopLeftRadius: 24,
|
|
636
|
+
borderTopRightRadius: 24,
|
|
637
|
+
},
|
|
638
|
+
sheetHandle: {
|
|
639
|
+
backgroundColor: COLORS.border,
|
|
640
|
+
width: 40,
|
|
641
|
+
},
|
|
642
|
+
sheetContent: {
|
|
643
|
+
paddingHorizontal: 24,
|
|
644
|
+
paddingTop: 8,
|
|
645
|
+
paddingBottom: 40,
|
|
646
|
+
gap: 12,
|
|
647
|
+
},
|
|
648
|
+
// Web detail modal — centered, bounded card instead of a bottom sheet.
|
|
649
|
+
webModalBackdrop: {
|
|
650
|
+
flex: 1,
|
|
651
|
+
backgroundColor: 'rgba(8, 10, 16, 0.6)',
|
|
652
|
+
alignItems: 'center',
|
|
653
|
+
justifyContent: 'center',
|
|
654
|
+
padding: 24,
|
|
655
|
+
},
|
|
656
|
+
webModalCard: {
|
|
657
|
+
width: '100%',
|
|
658
|
+
maxWidth: 560,
|
|
659
|
+
maxHeight: '90%',
|
|
660
|
+
backgroundColor: COLORS.surface,
|
|
661
|
+
borderRadius: 20,
|
|
662
|
+
borderWidth: 1,
|
|
663
|
+
borderColor: COLORS.border,
|
|
664
|
+
overflow: 'hidden',
|
|
665
|
+
},
|
|
666
|
+
webModalClose: {
|
|
667
|
+
position: 'absolute',
|
|
668
|
+
top: 12,
|
|
669
|
+
right: 12,
|
|
670
|
+
zIndex: 1,
|
|
671
|
+
width: 32,
|
|
672
|
+
height: 32,
|
|
673
|
+
borderRadius: 16,
|
|
674
|
+
alignItems: 'center',
|
|
675
|
+
justifyContent: 'center',
|
|
676
|
+
backgroundColor: COLORS.backgroundAlt,
|
|
677
|
+
},
|
|
678
|
+
sheetThumbnail: {
|
|
679
|
+
width: '100%',
|
|
680
|
+
aspectRatio: 4 / 3,
|
|
681
|
+
borderRadius: 16,
|
|
682
|
+
backgroundColor: COLORS.backgroundAlt,
|
|
683
|
+
},
|
|
684
|
+
sheetThumbnailPlaceholder: {
|
|
685
|
+
width: '100%',
|
|
686
|
+
aspectRatio: 4 / 3,
|
|
687
|
+
borderRadius: 16,
|
|
688
|
+
backgroundColor: COLORS.surfaceSoft,
|
|
689
|
+
alignItems: 'center',
|
|
690
|
+
justifyContent: 'center',
|
|
691
|
+
},
|
|
692
|
+
sheetPlaceholderText: {
|
|
693
|
+
color: COLORS.textMuted,
|
|
694
|
+
fontSize: 32,
|
|
695
|
+
fontWeight: '700',
|
|
696
|
+
letterSpacing: 3,
|
|
697
|
+
},
|
|
698
|
+
sheetName: {
|
|
699
|
+
color: COLORS.textPrimary,
|
|
700
|
+
fontSize: 22,
|
|
701
|
+
fontWeight: '700',
|
|
702
|
+
letterSpacing: -0.3,
|
|
703
|
+
},
|
|
704
|
+
sheetDescription: {
|
|
705
|
+
color: COLORS.textSecondary,
|
|
706
|
+
fontSize: 14,
|
|
707
|
+
lineHeight: 20,
|
|
708
|
+
},
|
|
709
|
+
statsRow: {
|
|
710
|
+
flexDirection: 'row',
|
|
711
|
+
alignItems: 'center',
|
|
712
|
+
gap: 16,
|
|
713
|
+
},
|
|
714
|
+
statItem: {
|
|
715
|
+
flexDirection: 'row',
|
|
716
|
+
alignItems: 'center',
|
|
717
|
+
gap: 4,
|
|
718
|
+
},
|
|
719
|
+
statText: {
|
|
720
|
+
color: COLORS.textSecondary,
|
|
721
|
+
fontSize: 13,
|
|
722
|
+
},
|
|
723
|
+
sheetBadgeRow: {
|
|
724
|
+
flexDirection: 'row',
|
|
725
|
+
gap: 8,
|
|
726
|
+
flexWrap: 'wrap',
|
|
727
|
+
},
|
|
728
|
+
sheetBadge: {
|
|
729
|
+
paddingHorizontal: 10,
|
|
730
|
+
paddingVertical: 5,
|
|
731
|
+
borderRadius: 8,
|
|
732
|
+
backgroundColor: 'rgba(108, 99, 255, 0.15)',
|
|
733
|
+
},
|
|
734
|
+
sheetAnimatedBadge: {
|
|
735
|
+
backgroundColor: 'rgba(59, 178, 115, 0.15)',
|
|
736
|
+
},
|
|
737
|
+
sheetReadyBadge: {
|
|
738
|
+
backgroundColor: 'rgba(72, 201, 138, 0.18)',
|
|
739
|
+
},
|
|
740
|
+
sheetReadyText: {
|
|
741
|
+
color: '#48c98a',
|
|
742
|
+
},
|
|
743
|
+
sheetLimitedBadge: {
|
|
744
|
+
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
|
745
|
+
},
|
|
746
|
+
sheetLimitedText: {
|
|
747
|
+
color: COLORS.textMuted,
|
|
748
|
+
},
|
|
749
|
+
sheetBadgeText: {
|
|
750
|
+
color: COLORS.accent,
|
|
751
|
+
fontSize: 12,
|
|
752
|
+
fontWeight: '600',
|
|
753
|
+
},
|
|
754
|
+
sheetMeta: {
|
|
755
|
+
color: COLORS.textMuted,
|
|
756
|
+
fontSize: 12,
|
|
757
|
+
},
|
|
758
|
+
importButton: {
|
|
759
|
+
marginTop: 8,
|
|
760
|
+
backgroundColor: COLORS.accent,
|
|
761
|
+
borderRadius: 14,
|
|
762
|
+
paddingVertical: 16,
|
|
763
|
+
alignItems: 'center',
|
|
764
|
+
shadowColor: COLORS.accent,
|
|
765
|
+
shadowOffset: { width: 0, height: 4 },
|
|
766
|
+
shadowOpacity: 0.4,
|
|
767
|
+
shadowRadius: 12,
|
|
768
|
+
elevation: 8,
|
|
769
|
+
},
|
|
770
|
+
importButtonDisabled: {
|
|
771
|
+
opacity: 0.7,
|
|
772
|
+
},
|
|
773
|
+
importButtonInner: {
|
|
774
|
+
flexDirection: 'row',
|
|
775
|
+
alignItems: 'center',
|
|
776
|
+
gap: 8,
|
|
777
|
+
},
|
|
778
|
+
importButtonText: {
|
|
779
|
+
color: '#fff',
|
|
780
|
+
fontSize: 16,
|
|
781
|
+
fontWeight: '700',
|
|
782
|
+
letterSpacing: 0.3,
|
|
783
|
+
},
|
|
784
|
+
progressContainer: {
|
|
785
|
+
marginTop: 8,
|
|
786
|
+
gap: 8,
|
|
787
|
+
alignItems: 'center',
|
|
788
|
+
},
|
|
789
|
+
progressTrack: {
|
|
790
|
+
width: '100%',
|
|
791
|
+
height: 8,
|
|
792
|
+
borderRadius: 4,
|
|
793
|
+
backgroundColor: COLORS.border,
|
|
794
|
+
overflow: 'hidden',
|
|
795
|
+
},
|
|
796
|
+
progressFill: {
|
|
797
|
+
height: '100%',
|
|
798
|
+
borderRadius: 4,
|
|
799
|
+
backgroundColor: COLORS.accent,
|
|
800
|
+
},
|
|
801
|
+
progressText: {
|
|
802
|
+
color: COLORS.textSecondary,
|
|
803
|
+
fontSize: 13,
|
|
804
|
+
fontWeight: '500',
|
|
805
|
+
},
|
|
806
|
+
});
|