talking-head-studio 0.4.0 → 0.4.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 (42) hide show
  1. package/dist/TalkingHeadVisualization.d.ts +1 -1
  2. package/dist/TalkingHeadVisualization.js +13 -13
  3. package/dist/editor/AvatarCanvas.d.ts +3 -14
  4. package/dist/editor/AvatarCanvas.js +5 -4
  5. package/dist/editor/AvatarEditor.d.ts +1 -0
  6. package/dist/editor/AvatarEditor.js +6 -0
  7. package/dist/editor/AvatarEditor.native.d.ts +4 -0
  8. package/dist/editor/AvatarEditor.native.js +93 -0
  9. package/dist/editor/boneSnap.d.ts +25 -0
  10. package/dist/editor/boneSnap.js +93 -0
  11. package/dist/editor/index.d.ts +4 -0
  12. package/dist/editor/index.js +13 -1
  13. package/dist/editor/types.d.ts +18 -4
  14. package/dist/utils/avatarUtils.d.ts +2 -3
  15. package/dist/utils/avatarUtils.js +5 -6
  16. package/dist/wardrobe/wardrobeStore.d.ts +1 -1
  17. package/dist/wgpu/WgpuAvatar.d.ts +3 -0
  18. package/dist/wgpu/WgpuAvatar.js +55 -87
  19. package/dist/{filament → wgpu}/morphTables.js +1 -1
  20. package/dist/wgpu/useAuthedModelUri.d.ts +11 -0
  21. package/dist/{filament/useAuthedFilamentUri.js → wgpu/useAuthedModelUri.js} +9 -9
  22. package/package.json +2 -15
  23. package/dist/filament/FilamentAvatar.d.ts +0 -41
  24. package/dist/filament/FilamentAvatar.js +0 -755
  25. package/dist/filament/editor/FilamentEditor.d.ts +0 -16
  26. package/dist/filament/editor/FilamentEditor.js +0 -880
  27. package/dist/filament/editor/FilamentEditor.web.d.ts +0 -19
  28. package/dist/filament/editor/FilamentEditor.web.js +0 -58
  29. package/dist/filament/editor/PrecisionPanel.d.ts +0 -1
  30. package/dist/filament/editor/PrecisionPanel.js +0 -252
  31. package/dist/filament/editor/boneSnap.d.ts +0 -10
  32. package/dist/filament/editor/boneSnap.js +0 -97
  33. package/dist/filament/editor/index.d.ts +0 -5
  34. package/dist/filament/editor/index.js +0 -19
  35. package/dist/filament/index.d.ts +0 -6
  36. package/dist/filament/index.js +0 -24
  37. package/dist/filament/useAuthedFilamentUri.d.ts +0 -11
  38. /package/dist/{filament/editor → editor}/studioTheme.d.ts +0 -0
  39. /package/dist/{filament/editor → editor}/studioTheme.js +0 -0
  40. /package/dist/{filament → wgpu}/faceSqueezeAssets.d.ts +0 -0
  41. /package/dist/{filament → wgpu}/faceSqueezeAssets.js +0 -0
  42. /package/dist/{filament → wgpu}/morphTables.d.ts +0 -0
@@ -1,880 +0,0 @@
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 (mod) {
19
- if (mod && mod.__esModule) return mod;
20
- var result = {};
21
- if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
- __setModuleDefault(result, mod);
23
- return result;
24
- };
25
- var __importDefault = (this && this.__importDefault) || function (mod) {
26
- return (mod && mod.__esModule) ? mod : { "default": mod };
27
- };
28
- Object.defineProperty(exports, "__esModule", { value: true });
29
- exports.FilamentEditor = void 0;
30
- const jsx_runtime_1 = require("react/jsx-runtime");
31
- /* eslint-disable @typescript-eslint/no-explicit-any */
32
- const vector_icons_1 = require("@expo/vector-icons");
33
- const Haptics = __importStar(require("expo-haptics"));
34
- const react_1 = require("react");
35
- const react_native_1 = require("react-native");
36
- const react_native_filament_1 = require("react-native-filament");
37
- const react_native_gesture_handler_1 = require("react-native-gesture-handler");
38
- const react_native_reanimated_1 = __importStar(require("react-native-reanimated"));
39
- const useAuthedFilamentUri_1 = require("../useAuthedFilamentUri");
40
- const boneSnap_1 = require("./boneSnap");
41
- const studioApi_1 = require("../../api/studioApi");
42
- const PrecisionPanel_1 = __importDefault(require("./PrecisionPanel"));
43
- const studioTheme_1 = require("./studioTheme");
44
- // Lazy require avoids mixing require/import at module top level (Metro bug).
45
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
46
- const useCameraManipulator = require('react-native-filament').useCameraManipulator;
47
- // Pure JS transform helpers (no 'worklet' directive — avoids Reanimated
48
- // babel plugin serializing gesture closure variables).
49
- function applyDragTranslate(startPos, translationX, translationY, sensitivity) {
50
- return [
51
- startPos[0] + translationX * sensitivity,
52
- startPos[1] - translationY * sensitivity,
53
- startPos[2],
54
- ];
55
- }
56
- function applyPinchScale(startScale, scaleFactor) {
57
- const clamp = (val) => Math.max(0.1, Math.min(val, 10));
58
- return [
59
- clamp(startScale[0] * scaleFactor),
60
- clamp(startScale[1] * scaleFactor),
61
- clamp(startScale[2] * scaleFactor),
62
- ];
63
- }
64
- function applyRotation(startRot, deltaX, deltaY) {
65
- return [startRot[0] + deltaY, startRot[1] + deltaX, startRot[2]];
66
- }
67
- // ---------------------------------------------------------------------------
68
- // Constants
69
- // ---------------------------------------------------------------------------
70
- const MAX_SLOTS = 4; // Adreno 612 (Moto G Stylus) GPU runs out of texture memory with >4 large GLBs
71
- const MAX_ACCESSORY_BYTES = 50 * 1024 * 1024; // 50 MB total budget for accessory GLBs
72
- const WORLD_RANGE = 4; // total world units the view spans (~4m for a humanoid scene)
73
- const DEFAULT_VIEW_WIDTH = 390; // fallback for pre-layout
74
- const COLORS = {
75
- bg: studioTheme_1.studioTheme.colors.background,
76
- surface: studioTheme_1.studioTheme.colors.surface,
77
- card: studioTheme_1.studioTheme.colors.surfaceRaised,
78
- border: studioTheme_1.studioTheme.colors.border,
79
- accent: studioTheme_1.studioTheme.colors.accent,
80
- textPrimary: studioTheme_1.studioTheme.colors.textPrimary,
81
- textSecondary: studioTheme_1.studioTheme.colors.textSecondary,
82
- };
83
- /**
84
- * Downloads a URL with auth, loads it into Filament, and only renders
85
- * AuthedModelInner once the file is cached locally.
86
- */
87
- function AuthedModel({ url, onFileSize, ...modelProps }) {
88
- const fileResult = (0, useAuthedFilamentUri_1.useAuthedFilamentUri)(url);
89
- const reportedRef = (0, react_1.useRef)(false);
90
- (0, react_1.useEffect)(() => {
91
- if (fileResult && !reportedRef.current) {
92
- reportedRef.current = true;
93
- onFileSize?.(fileResult.size);
94
- }
95
- }, [fileResult, onFileSize]);
96
- if (!fileResult) {
97
- console.log('[FilamentEditor] AuthedModel waiting for download:', url.slice(-40));
98
- return null;
99
- }
100
- return (0, jsx_runtime_1.jsx)(AuthedModelInner, { localUri: fileResult.uri, ...modelProps });
101
- }
102
- /**
103
- * Throttled transform updates via setTimeout (max ~5fps during gesture).
104
- *
105
- * Previous approaches all crashed on Adreno 612:
106
- * - React state + useEffect (300 JNI/sec) → SIGABRT (FabricUIManager null)
107
- * - Choreographer + Mat4 chain → SIGSEGV in Hermes (temp JSI objects freed)
108
- * - Direct imperative calls → SIGSEGV in libGLESv2_adreno (memcpy race)
109
- *
110
- * This approach: gesture handlers write to a ref (zero native calls), a
111
- * setTimeout at 200ms intervals drains pending values with isValid guards
112
- * and try/catch. 5 native flushes/sec is safe for Adreno 612.
113
- */
114
- const TRANSFORM_THROTTLE_MS = 200;
115
- /** Largest axis of a new accessory's bounding box will be clamped to this (world units ≈ metres). */
116
- const AUTO_NORMALIZE_MAX_SIZE = 0.4;
117
- function AuthedModelInner({ localUri, initialTranslate, initialRotate, initialScale, onPress, onLoaded, onTransformReady, autoNormalizeMaxSize, boneWorldPos, onAutoNormalized, }) {
118
- const model = (0, react_native_filament_1.useModel)({ uri: localUri });
119
- const { transformManager } = (0, react_native_filament_1.useFilamentContext)();
120
- const notifiedRef = (0, react_1.useRef)(false);
121
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
122
- const entityRef = (0, react_1.useRef)(undefined);
123
- const pendingTransform = (0, react_1.useRef)(null);
124
- const pendingDirty = (0, react_1.useRef)(false);
125
- const throttleTimer = (0, react_1.useRef)(null);
126
- const unmounted = (0, react_1.useRef)(false);
127
- // Apply pending transform with safety guards
128
- const flushTransform = (0, react_1.useCallback)(() => {
129
- if (unmounted.current)
130
- return;
131
- const entity = entityRef.current;
132
- if (!entity || !pendingDirty.current)
133
- return;
134
- const xform = pendingTransform.current;
135
- if (!xform)
136
- return;
137
- if (!transformManager.isValid)
138
- return;
139
- pendingDirty.current = false;
140
- try {
141
- transformManager.setEntityScale(entity, xform.s, false);
142
- transformManager.setEntityRotation(entity, xform.r[0], [1, 0, 0], true);
143
- transformManager.setEntityRotation(entity, xform.r[1], [0, 1, 0], true);
144
- transformManager.setEntityRotation(entity, xform.r[2], [0, 0, 1], true);
145
- transformManager.setEntityPosition(entity, xform.t, true);
146
- }
147
- catch (e) {
148
- console.warn('[FilamentEditor] Transform failed (native object may be freed):', e);
149
- }
150
- }, [transformManager]);
151
- // Queue a transform — writes to ref, schedules throttled native flush
152
- const queueTransform = (0, react_1.useCallback)((t, r, s) => {
153
- pendingTransform.current = { t, r, s };
154
- pendingDirty.current = true;
155
- // Deduplicated throttle: one timer at a time
156
- if (!throttleTimer.current && !unmounted.current) {
157
- throttleTimer.current = setTimeout(() => {
158
- throttleTimer.current = null;
159
- flushTransform();
160
- }, TRANSFORM_THROTTLE_MS);
161
- }
162
- }, [flushTransform]);
163
- // Cleanup on unmount
164
- (0, react_1.useEffect)(() => {
165
- unmounted.current = false;
166
- return () => {
167
- unmounted.current = true;
168
- if (throttleTimer.current) {
169
- clearTimeout(throttleTimer.current);
170
- throttleTimer.current = null;
171
- }
172
- };
173
- }, []);
174
- // Once loaded: apply initial transform (with optional auto-normalization), notify parent
175
- (0, react_1.useEffect)(() => {
176
- if (model.state !== 'loaded')
177
- return;
178
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
179
- entityRef.current = model.rootEntity;
180
- if (!notifiedRef.current) {
181
- notifiedRef.current = true;
182
- let t = initialTranslate ?? [0, 0, 0];
183
- const r = initialRotate ?? [0, 0, 0];
184
- let s = initialScale ?? [1, 1, 1];
185
- // Auto-normalize: scale model so its largest AABB dimension ≤ autoNormalizeMaxSize,
186
- // then center it on the bone position.
187
- // In Filament TRS order: world_vertex = position + scale * local_vertex
188
- // So to put the bbox center at bone pos: position = bonePos - center * scale
189
- const bb = model.boundingBox;
190
- if (autoNormalizeMaxSize && bb) {
191
- const { center, halfExtent } = bb;
192
- const minY = center[1] - halfExtent[1]; // bottom of bbox in local space
193
- const maxExtent = Math.max(halfExtent[0], halfExtent[1], halfExtent[2]) * 2;
194
- if (maxExtent > 0) {
195
- const uniformScale = Math.min(autoNormalizeMaxSize / maxExtent, 1.0);
196
- s = [uniformScale, uniformScale, uniformScale];
197
- const bone = boneWorldPos ?? [0, 0, 0];
198
- t = [
199
- bone[0] - center[0] * uniformScale,
200
- bone[1] - minY * uniformScale,
201
- bone[2] - center[2] * uniformScale,
202
- ];
203
- const relOffset = [
204
- -center[0] * uniformScale,
205
- -minY * uniformScale,
206
- -center[2] * uniformScale,
207
- ];
208
- console.log(`[FilamentEditor] Auto-normalized: maxExtent=${maxExtent.toFixed(3)} → scale=${uniformScale.toFixed(4)}`);
209
- onAutoNormalized?.(relOffset, uniformScale);
210
- }
211
- }
212
- pendingTransform.current = { t, r, s };
213
- pendingDirty.current = true;
214
- flushTransform();
215
- onTransformReady?.(queueTransform);
216
- onLoaded?.();
217
- }
218
- // eslint-disable-next-line react-hooks/exhaustive-deps
219
- }, [model.state]);
220
- if (model.state === 'loading')
221
- return null;
222
- const MR = react_native_filament_1.ModelRenderer;
223
- return (0, jsx_runtime_1.jsx)(MR, { model: model, onPress: onPress });
224
- }
225
- // ---------------------------------------------------------------------------
226
- // AvatarModel — loads avatar and signals when ready (no transforms needed)
227
- // ---------------------------------------------------------------------------
228
- const SKIN_MATERIAL_KEYWORDS = ['skin', 'body', 'flesh', 'face', 'head'];
229
- function hexToFloat4(hex) {
230
- const cleaned = hex.replace('#', '');
231
- const r = parseInt(cleaned.slice(0, 2), 16) / 255;
232
- const g = parseInt(cleaned.slice(2, 4), 16) / 255;
233
- const b = parseInt(cleaned.slice(4, 6), 16) / 255;
234
- return [r, g, b, 1.0];
235
- }
236
- function AvatarModel({ uri, onLoaded, skinColor }) {
237
- const model = (0, react_native_filament_1.useModel)({ uri });
238
- const didNotify = (0, react_1.useRef)(false);
239
- const { renderableManager } = (0, react_native_filament_1.useFilamentContext)();
240
- (0, react_1.useEffect)(() => {
241
- if (model.state === 'loaded' && !didNotify.current) {
242
- didNotify.current = true;
243
- onLoaded();
244
- }
245
- }, [model.state, onLoaded]);
246
- (0, react_1.useEffect)(() => {
247
- if (model.state !== 'loaded' || !skinColor)
248
- return;
249
- const color = hexToFloat4(skinColor);
250
- const entities = model.asset.getRenderableEntities();
251
- const skinEntities = [];
252
- // First pass: collect entities with skin-named materials
253
- for (const entity of entities) {
254
- const count = renderableManager.getPrimitiveCount(entity);
255
- for (let i = 0; i < count; i++) {
256
- const mi = renderableManager.getMaterialInstanceAt(entity, i);
257
- const lower = (mi.name ?? '').toLowerCase();
258
- if (SKIN_MATERIAL_KEYWORDS.some((k) => lower.includes(k))) {
259
- mi.setFloat4Parameter('baseColorFactor', color);
260
- skinEntities.push(entity);
261
- break;
262
- }
263
- }
264
- }
265
- // Fallback: if no skin-named materials found, tint everything
266
- if (skinEntities.length === 0) {
267
- for (const entity of entities) {
268
- const count = renderableManager.getPrimitiveCount(entity);
269
- for (let i = 0; i < count; i++) {
270
- const mi = renderableManager.getMaterialInstanceAt(entity, i);
271
- mi.setFloat4Parameter('baseColorFactor', color);
272
- }
273
- }
274
- }
275
- }, [model.state, skinColor, renderableManager]);
276
- if (model.state === 'loading')
277
- return null;
278
- const MR = react_native_filament_1.ModelRenderer;
279
- return (0, jsx_runtime_1.jsx)(MR, { model: model, castShadow: true, receiveShadow: true });
280
- }
281
- // ---------------------------------------------------------------------------
282
- // Outer component — provides <FilamentScene> context
283
- // ---------------------------------------------------------------------------
284
- function FilamentEditor(props) {
285
- return ((0, jsx_runtime_1.jsx)(react_native_filament_1.FilamentScene, { children: (0, jsx_runtime_1.jsx)(FilamentEditorContent, { ...props }) }));
286
- }
287
- exports.FilamentEditor = FilamentEditor;
288
- // ---------------------------------------------------------------------------
289
- // Inner component — has access to Filament context
290
- // ---------------------------------------------------------------------------
291
- function FilamentEditorContent({ avatarUrl, style, onDone, skinColor, equipped, placements, activeAssetId, onActiveAssetChange, onPlacementChange, }) {
292
- // Pre-download avatar GLB with auth (Filament's native fetcher doesn't send auth headers)
293
- const avatarFile = (0, useAuthedFilamentUri_1.useAuthedFilamentUri)(avatarUrl);
294
- const localAvatarUri = avatarFile?.uri ?? null;
295
- // Track whether the avatar model has been loaded by Filament's native engine.
296
- // Accessories must NOT mount until the avatar is ready — loading two models
297
- // concurrently causes a null-pointer crash in the Adreno GPU driver's
298
- // SurfaceTexture thread (SEGV_MAPERR in libGLESv2_adreno.so → memcpy).
299
- const [avatarReady, setAvatarReady] = (0, react_1.useState)(false);
300
- (0, react_1.useEffect)(() => {
301
- console.log('[FilamentEditor] avatarUrl:', avatarUrl);
302
- console.log('[FilamentEditor] localAvatarUri:', localAvatarUri);
303
- console.log('[FilamentEditor] equipped slots:', Object.keys(equipped).length);
304
- }, [avatarUrl, localAvatarUri, equipped]);
305
- // -------------------------------------------------------------------------
306
- // Local state
307
- // -------------------------------------------------------------------------
308
- const [showPrecision, setShowPrecision] = (0, react_1.useState)(false);
309
- const [viewWidth, setViewWidth] = (0, react_1.useState)(DEFAULT_VIEW_WIDTH);
310
- const sensitivity = (0, react_1.useMemo)(() => WORLD_RANGE / viewWidth, [viewWidth]);
311
- // -------------------------------------------------------------------------
312
- // Camera
313
- // -------------------------------------------------------------------------
314
- const manipulator = useCameraManipulator({
315
- orbitHomePosition: [0, 1.6, 1.8],
316
- targetPosition: [0, 1.5, 0],
317
- });
318
- // -------------------------------------------------------------------------
319
- // Transform state — refs only (no React state for transforms)
320
- //
321
- // Transforms are applied imperatively via slotTransformApply refs.
322
- // React state is NOT used for per-frame transform updates during gesture —
323
- // that pattern causes 300+ JNI calls/second → FabricUIManager null crash.
324
- // -------------------------------------------------------------------------
325
- // Refs: current transform values (synchronous read/write in gesture handlers)
326
- const positionRefs = (0, react_1.useRef)(Array.from({ length: MAX_SLOTS }, () => [0, 0, 0]));
327
- const rotationRefs = (0, react_1.useRef)(Array.from({ length: MAX_SLOTS }, () => [0, 0, 0]));
328
- const scaleRefs = (0, react_1.useRef)(Array.from({ length: MAX_SLOTS }, () => [1, 1, 1]));
329
- // Imperative transform handles: set by AuthedModelInner once model is loaded
330
- const slotTransformApply = (0, react_1.useRef)(Array.from({ length: MAX_SLOTS }, () => null));
331
- // Slots that have no saved placement yet — eligible for bounding-box auto-normalization
332
- const newAccessorySlots = (0, react_1.useRef)(new Set());
333
- // Apply transforms directly — no setState, no re-render, no useEffect
334
- const applySlotTransform = (0, react_1.useCallback)((slotIdx, pos, rot, scl) => {
335
- positionRefs.current[slotIdx] = pos;
336
- rotationRefs.current[slotIdx] = rot;
337
- scaleRefs.current[slotIdx] = scl;
338
- slotTransformApply.current[slotIdx]?.(pos, rot, scl);
339
- }, []);
340
- // Selection pulse animation (Reanimated for UI animations)
341
- const selectionScale = (0, react_native_reanimated_1.useSharedValue)(1);
342
- // -------------------------------------------------------------------------
343
- // Map equipped assets → fixed slot indices
344
- // -------------------------------------------------------------------------
345
- const slotMappings = (0, react_1.useMemo)(() => {
346
- const assets = Object.values(equipped).filter(Boolean);
347
- if (assets.length > MAX_SLOTS) {
348
- console.warn(`[FilamentEditor] ${assets.length} accessories equipped but max is ${MAX_SLOTS} for this GPU. ` +
349
- `${assets.length - MAX_SLOTS} will not render in the editor.`);
350
- }
351
- return assets.slice(0, MAX_SLOTS).map((asset) => ({
352
- assetId: asset.id,
353
- asset,
354
- }));
355
- }, [equipped]);
356
- // Reverse lookup: assetId → slot index
357
- const assetIdToSlot = (0, react_1.useMemo)(() => {
358
- const map = {};
359
- slotMappings.forEach((m, i) => {
360
- map[m.assetId] = i;
361
- });
362
- return map;
363
- }, [slotMappings]);
364
- // -------------------------------------------------------------------------
365
- // Sync prop placements → transform refs (only on equip/unequip, not every
366
- // placement update — gestures write refs directly, so re-syncing on every
367
- // onPlacementChange would cause a circular update and stutter)
368
- // -------------------------------------------------------------------------
369
- (0, react_1.useEffect)(() => {
370
- for (let i = 0; i < slotMappings.length; i++) {
371
- const { assetId, asset } = slotMappings[i];
372
- const p = placements[assetId];
373
- if (p) {
374
- const bonePos = boneSnap_1.HUMANOID_BONES[p.bone] ?? boneSnap_1.HUMANOID_BONES['Head'];
375
- const worldPos = [
376
- p.position[0] + bonePos[0],
377
- p.position[1] + bonePos[1],
378
- p.position[2] + bonePos[2],
379
- ];
380
- positionRefs.current[i] = worldPos;
381
- rotationRefs.current[i] = p.rotation;
382
- scaleRefs.current[i] = [p.scale, p.scale, p.scale];
383
- }
384
- else {
385
- // Auto-snap: place new accessories at their attach_bone position
386
- const boneName = asset.attach_bone ?? 'Head';
387
- const bonePos = boneSnap_1.HUMANOID_BONES[boneName] ?? boneSnap_1.HUMANOID_BONES['Head'];
388
- positionRefs.current[i] = bonePos;
389
- rotationRefs.current[i] = [0, 0, 0];
390
- scaleRefs.current[i] = [1, 1, 1];
391
- // Mark for auto-normalization (bounding box read after GLB loads)
392
- newAccessorySlots.current.add(i);
393
- // Persist the initial placement via prop callback
394
- onPlacementChange(assetId, {
395
- bone: boneName,
396
- position: [0, 0, 0],
397
- rotation: [0, 0, 0],
398
- scale: 1,
399
- });
400
- }
401
- }
402
- // eslint-disable-next-line react-hooks/exhaustive-deps
403
- }, [slotMappings]);
404
- // -------------------------------------------------------------------------
405
- // React to prop placement changes (from PrecisionPanel sliders) and sync
406
- // them to the 3D scene so the model updates live.
407
- // Skipped during active gestures (gesture handlers update refs directly).
408
- // -------------------------------------------------------------------------
409
- const isGestureActive = (0, react_1.useRef)(false);
410
- // Track prev placements to diff — only sync changed entries
411
- const prevPlacementsRef = (0, react_1.useRef)(placements);
412
- (0, react_1.useEffect)(() => {
413
- if (isGestureActive.current) {
414
- prevPlacementsRef.current = placements;
415
- return;
416
- }
417
- const prevPlacements = prevPlacementsRef.current;
418
- prevPlacementsRef.current = placements;
419
- if (placements === prevPlacements)
420
- return;
421
- for (let i = 0; i < slotMappings.length; i++) {
422
- const { assetId } = slotMappings[i];
423
- const p = placements[assetId];
424
- const prev = prevPlacements[assetId];
425
- if (!p || p === prev)
426
- continue;
427
- const bonePos = boneSnap_1.HUMANOID_BONES[p.bone] ?? boneSnap_1.HUMANOID_BONES['Head'];
428
- const worldPos = [
429
- p.position[0] + bonePos[0],
430
- p.position[1] + bonePos[1],
431
- p.position[2] + bonePos[2],
432
- ];
433
- positionRefs.current[i] = worldPos;
434
- rotationRefs.current[i] = p.rotation;
435
- scaleRefs.current[i] = [p.scale, p.scale, p.scale];
436
- applySlotTransform(i, worldPos, p.rotation, [p.scale, p.scale, p.scale]);
437
- }
438
- }, [placements, slotMappings, applySlotTransform]);
439
- // -------------------------------------------------------------------------
440
- // Gesture start snapshots (stored in refs for worklet access)
441
- // -------------------------------------------------------------------------
442
- const gestureStartPos = (0, react_1.useRef)([0, 0, 0]);
443
- const gestureStartScale = (0, react_1.useRef)([1, 1, 1]);
444
- const gestureStartRot = (0, react_1.useRef)([0, 0, 0]);
445
- const isTouchingAccessory = (0, react_1.useRef)(false);
446
- const hitScaleBound = (0, react_1.useRef)(false);
447
- // Pending commit: written during gesture updates so commitActiveTransform
448
- // doesn't need to read from positionRefs/rotationRefs/scaleRefs (which may
449
- // be frozen by Reanimated's babel plugin serialization).
450
- const pendingCommit = (0, react_1.useRef)(null);
451
- // -------------------------------------------------------------------------
452
- // Helpers
453
- // -------------------------------------------------------------------------
454
- const onLayout = (0, react_1.useCallback)((e) => {
455
- setViewWidth(e.nativeEvent.layout.width);
456
- }, []);
457
- const selectAccessory = (0, react_1.useCallback)((assetId) => {
458
- onActiveAssetChange(assetId);
459
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
460
- // Selection pulse
461
- selectionScale.value = (0, react_native_reanimated_1.withSpring)(1.05, { damping: 8, stiffness: 300 }, () => {
462
- selectionScale.value = (0, react_native_reanimated_1.withSpring)(1, { damping: 12, stiffness: 200 });
463
- });
464
- }, [onActiveAssetChange, selectionScale]);
465
- const deselectAccessory = (0, react_1.useCallback)(() => {
466
- onActiveAssetChange(null);
467
- setShowPrecision(false);
468
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
469
- }, [onActiveAssetChange]);
470
- const commitActiveTransform = (0, react_1.useCallback)(() => {
471
- const commit = pendingCommit.current;
472
- if (!commit)
473
- return;
474
- // Use the current placement from props to get the bone (read from prop — up-to-date)
475
- const currentPlacement = placements[commit.assetId];
476
- const bone = currentPlacement?.bone ?? 'Head';
477
- const bonePos = boneSnap_1.HUMANOID_BONES[bone] ?? boneSnap_1.HUMANOID_BONES['Head'];
478
- const relativePos = [
479
- commit.pos[0] - bonePos[0],
480
- commit.pos[1] - bonePos[1],
481
- commit.pos[2] - bonePos[2],
482
- ];
483
- onPlacementChange(commit.assetId, {
484
- bone,
485
- position: relativePos,
486
- rotation: [...commit.rot],
487
- scale: commit.scl[0], // uniform scale
488
- });
489
- pendingCommit.current = null;
490
- }, [onPlacementChange, placements]);
491
- const snapToNearestBone = (0, react_1.useCallback)(() => {
492
- if (!activeAssetId)
493
- return;
494
- const slotIdx = assetIdToSlot[activeAssetId];
495
- if (slotIdx === undefined)
496
- return;
497
- const pos = positionRefs.current[slotIdx];
498
- const nearest = (0, boneSnap_1.findNearestBone)([...pos]);
499
- positionRefs.current[slotIdx] = nearest.position;
500
- applySlotTransform(slotIdx, nearest.position, rotationRefs.current[slotIdx], scaleRefs.current[slotIdx]);
501
- // Read fresh from prop placements (passed in as a stable-enough reference)
502
- const currentPlacement = placements[activeAssetId];
503
- onPlacementChange(activeAssetId, {
504
- bone: nearest.bone,
505
- position: [0, 0, 0], // Snapping directly to a bone means 0 relative offset
506
- rotation: currentPlacement?.rotation ?? [0, 0, 0],
507
- scale: currentPlacement?.scale ?? 1,
508
- });
509
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
510
- // eslint-disable-next-line react-hooks/exhaustive-deps
511
- }, [activeAssetId, assetIdToSlot, onPlacementChange, placements]);
512
- const resetToAttachBone = (0, react_1.useCallback)(() => {
513
- if (!activeAssetId)
514
- return;
515
- const slotIdx = assetIdToSlot[activeAssetId];
516
- if (slotIdx === undefined)
517
- return;
518
- const asset = slotMappings[slotIdx]?.asset;
519
- const boneName = asset?.attach_bone ?? 'Head';
520
- const bonePos = boneSnap_1.HUMANOID_BONES[boneName] ?? boneSnap_1.HUMANOID_BONES['Head'];
521
- positionRefs.current[slotIdx] = bonePos;
522
- rotationRefs.current[slotIdx] = [0, 0, 0];
523
- scaleRefs.current[slotIdx] = [1, 1, 1];
524
- applySlotTransform(slotIdx, bonePos, [0, 0, 0], [1, 1, 1]);
525
- onPlacementChange(activeAssetId, {
526
- bone: boneName,
527
- position: [0, 0, 0], // Relative to attach bone is 0
528
- rotation: [0, 0, 0],
529
- scale: 1,
530
- });
531
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
532
- // eslint-disable-next-line react-hooks/exhaustive-deps
533
- }, [activeAssetId, assetIdToSlot, slotMappings, onPlacementChange]);
534
- // -------------------------------------------------------------------------
535
- // Entity picking — stable onPress callbacks per assetId
536
- // -------------------------------------------------------------------------
537
- const onPressCallbacks = (0, react_1.useRef)({});
538
- // Rebuild only when selectAccessory reference changes (i.e. almost never)
539
- (0, react_1.useEffect)(() => {
540
- onPressCallbacks.current = {};
541
- }, [selectAccessory]);
542
- const getOnPress = (0, react_1.useCallback)((assetId) => {
543
- if (!onPressCallbacks.current[assetId]) {
544
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
545
- onPressCallbacks.current[assetId] = (_entity, _modelEntities) => {
546
- selectAccessory(assetId);
547
- isTouchingAccessory.current = true;
548
- };
549
- }
550
- return onPressCallbacks.current[assetId];
551
- }, [selectAccessory]);
552
- // Sequential accessory mounting with GPU memory budget (50 MB).
553
- // Loading multiple large GLBs simultaneously crashes the Adreno GPU driver
554
- // (SIGSEGV in JNISurfaceTextu when buffers arrive while render thread is busy).
555
- // The budget prevents loading so many that the GPU runs out of texture memory.
556
- const [mountedSlots, setMountedSlots] = (0, react_1.useState)(0);
557
- const totalLoadedBytes = (0, react_1.useRef)(0);
558
- const [budgetExhausted, setBudgetExhausted] = (0, react_1.useState)(false);
559
- (0, react_1.useEffect)(() => {
560
- if (avatarReady && mountedSlots === 0 && slotMappings.length > 0) {
561
- setMountedSlots(1);
562
- }
563
- }, [avatarReady, slotMappings.length, mountedSlots]);
564
- const onAccessoryFileSize = (0, react_1.useCallback)((bytes) => {
565
- totalLoadedBytes.current += bytes;
566
- console.log(`[FilamentEditor] Accessory loaded: ${(bytes / 1024 / 1024).toFixed(1)}MB, ` +
567
- `total: ${(totalLoadedBytes.current / 1024 / 1024).toFixed(1)}MB / ${(MAX_ACCESSORY_BYTES / 1024 / 1024).toFixed(0)}MB`);
568
- if (totalLoadedBytes.current >= MAX_ACCESSORY_BYTES) {
569
- console.warn('[FilamentEditor] GPU memory budget exhausted — skipping remaining accessories');
570
- setBudgetExhausted(true);
571
- }
572
- }, []);
573
- const onAccessoryLoaded = (0, react_1.useCallback)(() => {
574
- if (totalLoadedBytes.current >= MAX_ACCESSORY_BYTES) {
575
- console.warn('[FilamentEditor] GPU budget reached — not mounting more accessories');
576
- return;
577
- }
578
- setMountedSlots((prev) => Math.min(prev + 1, slotMappings.length));
579
- }, [slotMappings.length]);
580
- // --- Pan: drag accessory (if active) or orbit camera ---
581
- // .runOnJS(true) ensures callbacks run on JS thread without Reanimated
582
- // serialization, so refs are accessible and mutable.
583
- const panGesture = react_native_gesture_handler_1.Gesture.Pan()
584
- .maxPointers(1)
585
- .runOnJS(true)
586
- .onBegin((e) => {
587
- if (activeAssetId) {
588
- const slotIdx = assetIdToSlot[activeAssetId];
589
- if (slotIdx !== undefined) {
590
- gestureStartPos.current = [...positionRefs.current[slotIdx]];
591
- isTouchingAccessory.current = true;
592
- isGestureActive.current = true;
593
- pendingCommit.current = {
594
- assetId: activeAssetId,
595
- slotIdx,
596
- pos: [...positionRefs.current[slotIdx]],
597
- rot: [...rotationRefs.current[slotIdx]],
598
- scl: [...scaleRefs.current[slotIdx]],
599
- };
600
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
601
- }
602
- }
603
- else if (manipulator) {
604
- manipulator.grabBegin(e.x, e.y, false);
605
- }
606
- })
607
- .onUpdate((e) => {
608
- if (activeAssetId && isTouchingAccessory.current) {
609
- const slotIdx = assetIdToSlot[activeAssetId];
610
- if (slotIdx !== undefined) {
611
- const newPos = applyDragTranslate(gestureStartPos.current, e.translationX, e.translationY, sensitivity);
612
- applySlotTransform(slotIdx, newPos, rotationRefs.current[slotIdx], scaleRefs.current[slotIdx]);
613
- // Keep pendingCommit up to date with latest position
614
- if (pendingCommit.current) {
615
- pendingCommit.current.pos = newPos;
616
- }
617
- }
618
- }
619
- else if (manipulator) {
620
- manipulator.grabUpdate(e.x, e.y);
621
- }
622
- })
623
- .onEnd(() => {
624
- if (activeAssetId && isTouchingAccessory.current) {
625
- commitActiveTransform();
626
- }
627
- if (manipulator) {
628
- manipulator.grabEnd();
629
- }
630
- isTouchingAccessory.current = false;
631
- isGestureActive.current = false;
632
- })
633
- .minDistance(5);
634
- // --- Pinch: scale accessory (if active) or zoom camera ---
635
- const pinchGesture = react_native_gesture_handler_1.Gesture.Pinch()
636
- .runOnJS(true)
637
- .onBegin(() => {
638
- if (activeAssetId) {
639
- const slotIdx = assetIdToSlot[activeAssetId];
640
- if (slotIdx !== undefined) {
641
- gestureStartScale.current = [...scaleRefs.current[slotIdx]];
642
- hitScaleBound.current = false;
643
- isGestureActive.current = true;
644
- pendingCommit.current = {
645
- assetId: activeAssetId,
646
- slotIdx,
647
- pos: [...positionRefs.current[slotIdx]],
648
- rot: [...rotationRefs.current[slotIdx]],
649
- scl: [...scaleRefs.current[slotIdx]],
650
- };
651
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
652
- }
653
- }
654
- })
655
- .onUpdate((e) => {
656
- if (activeAssetId) {
657
- const slotIdx = assetIdToSlot[activeAssetId];
658
- if (slotIdx !== undefined) {
659
- const newScale = applyPinchScale(gestureStartScale.current, e.scale);
660
- applySlotTransform(slotIdx, positionRefs.current[slotIdx], rotationRefs.current[slotIdx], newScale);
661
- if (pendingCommit.current) {
662
- pendingCommit.current.scl = newScale;
663
- }
664
- // Haptic feedback at scale boundaries (0.1 or 10)
665
- const atBound = newScale[0] <= 0.1 || newScale[0] >= 10;
666
- if (atBound && !hitScaleBound.current) {
667
- hitScaleBound.current = true;
668
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
669
- }
670
- else if (!atBound) {
671
- hitScaleBound.current = false;
672
- }
673
- }
674
- }
675
- else if (manipulator) {
676
- manipulator.scroll(e.focalX, e.focalY, (1 - e.scale) * 2);
677
- }
678
- })
679
- .onEnd(() => {
680
- isGestureActive.current = false;
681
- if (activeAssetId) {
682
- commitActiveTransform();
683
- }
684
- });
685
- // --- Rotation: rotate accessory Y axis ---
686
- const rotationGesture = react_native_gesture_handler_1.Gesture.Rotation()
687
- .runOnJS(true)
688
- .onBegin(() => {
689
- if (activeAssetId) {
690
- const slotIdx = assetIdToSlot[activeAssetId];
691
- if (slotIdx !== undefined) {
692
- gestureStartRot.current = [...rotationRefs.current[slotIdx]];
693
- isGestureActive.current = true;
694
- pendingCommit.current = {
695
- assetId: activeAssetId,
696
- slotIdx,
697
- pos: [...positionRefs.current[slotIdx]],
698
- rot: [...rotationRefs.current[slotIdx]],
699
- scl: [...scaleRefs.current[slotIdx]],
700
- };
701
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
702
- }
703
- }
704
- })
705
- .onUpdate((e) => {
706
- if (activeAssetId) {
707
- const slotIdx = assetIdToSlot[activeAssetId];
708
- if (slotIdx !== undefined) {
709
- const newRot = applyRotation(gestureStartRot.current, e.rotation, 0);
710
- applySlotTransform(slotIdx, positionRefs.current[slotIdx], newRot, scaleRefs.current[slotIdx]);
711
- if (pendingCommit.current) {
712
- pendingCommit.current.rot = newRot;
713
- }
714
- }
715
- }
716
- })
717
- .onEnd(() => {
718
- isGestureActive.current = false;
719
- if (activeAssetId) {
720
- commitActiveTransform();
721
- }
722
- });
723
- // --- Long press: bone snap ---
724
- const longPressGesture = react_native_gesture_handler_1.Gesture.LongPress()
725
- .runOnJS(true)
726
- .minDuration(500)
727
- .onEnd((_e, success) => {
728
- if (success && activeAssetId) {
729
- snapToNearestBone();
730
- }
731
- });
732
- // Compose gestures:
733
- // We use Simultaneous for everything because placing Pan in a Race with Pinch
734
- // means the first finger touching the screen locks out Pinch completely!
735
- const pinchRotate = react_native_gesture_handler_1.Gesture.Simultaneous(pinchGesture, rotationGesture);
736
- const mainGesture = react_native_gesture_handler_1.Gesture.Simultaneous(longPressGesture, panGesture, pinchRotate);
737
- // -------------------------------------------------------------------------
738
- // Overlay animated styles
739
- // -------------------------------------------------------------------------
740
- const overlayOpacity = (0, react_native_reanimated_1.useSharedValue)(activeAssetId ? 1 : 0);
741
- (0, react_1.useEffect)(() => {
742
- overlayOpacity.value = (0, react_native_reanimated_1.withTiming)(activeAssetId ? 1 : 0, { duration: 200 });
743
- }, [activeAssetId, overlayOpacity]);
744
- const overlayAnimatedStyle = (0, react_native_reanimated_1.useAnimatedStyle)(() => ({
745
- opacity: overlayOpacity.value,
746
- pointerEvents: overlayOpacity.value > 0.5 ? 'auto' : 'none',
747
- }));
748
- // Selection pulse applied to the asset name badge
749
- const selectionBadgeStyle = (0, react_native_reanimated_1.useAnimatedStyle)(() => ({
750
- transform: [{ scale: selectionScale.value }],
751
- }));
752
- // Find active asset name for overlay
753
- const activeAssetName = (0, react_1.useMemo)(() => {
754
- if (!activeAssetId)
755
- return '';
756
- const mapping = slotMappings.find((m) => m.assetId === activeAssetId);
757
- return mapping?.asset.name ?? 'Accessory';
758
- }, [activeAssetId, slotMappings]);
759
- // -------------------------------------------------------------------------
760
- // Render
761
- // -------------------------------------------------------------------------
762
- return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.container, style], onLayout: onLayout, children: [(0, jsx_runtime_1.jsx)(react_native_gesture_handler_1.GestureDetector, { gesture: mainGesture, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.sceneContainer, children: (0, jsx_runtime_1.jsxs)(react_native_filament_1.FilamentView, { style: styles.filamentView, children: [(0, jsx_runtime_1.jsx)(react_native_filament_1.DefaultLight, {}), manipulator && react_native_filament_1.Camera({ cameraManipulator: manipulator }), localAvatarUri && ((0, jsx_runtime_1.jsx)(AvatarModel, { uri: localAvatarUri, skinColor: skinColor, onLoaded: () => {
763
- console.log('[FilamentEditor] Avatar model loaded, accessories may now mount');
764
- setAvatarReady(true);
765
- } })), slotMappings.slice(0, mountedSlots).map((mapping, slotIdx) => {
766
- if (slotIdx >= MAX_SLOTS) {
767
- console.warn('[FilamentEditor] Too many accessories, skipping:', mapping.assetId);
768
- return null;
769
- }
770
- return ((0, jsx_runtime_1.jsx)(AuthedModel, { url: (0, studioApi_1.assetFileUrl)(mapping.asset), initialTranslate: positionRefs.current[slotIdx], initialRotate: rotationRefs.current[slotIdx], initialScale: scaleRefs.current[slotIdx], onFileSize: onAccessoryFileSize, onTransformReady: (fn) => {
771
- slotTransformApply.current[slotIdx] = fn;
772
- }, onPress: getOnPress(mapping.assetId), onLoaded: slotIdx === mountedSlots - 1 && !budgetExhausted ? onAccessoryLoaded : undefined, ...(newAccessorySlots.current.has(slotIdx) && {
773
- autoNormalizeMaxSize: AUTO_NORMALIZE_MAX_SIZE,
774
- boneWorldPos: boneSnap_1.HUMANOID_BONES[mapping.asset.attach_bone ?? 'Head'] ?? boneSnap_1.HUMANOID_BONES['Head'],
775
- onAutoNormalized: (relOffset, scale) => {
776
- const bone = boneSnap_1.HUMANOID_BONES[mapping.asset.attach_bone ?? 'Head'] ?? boneSnap_1.HUMANOID_BONES['Head'];
777
- positionRefs.current[slotIdx] = [
778
- bone[0] + relOffset[0],
779
- bone[1] + relOffset[1],
780
- bone[2] + relOffset[2],
781
- ];
782
- scaleRefs.current[slotIdx] = [scale, scale, scale];
783
- newAccessorySlots.current.delete(slotIdx);
784
- onPlacementChange(mapping.assetId, {
785
- bone: mapping.asset.attach_bone ?? 'Head',
786
- position: [relOffset[0], relOffset[1], relOffset[2]],
787
- rotation: [0, 0, 0],
788
- scale,
789
- });
790
- },
791
- }) }, mapping.assetId));
792
- })] }) }) }), (0, jsx_runtime_1.jsxs)(react_native_reanimated_1.default.View, { style: [styles.overlay, overlayAnimatedStyle], pointerEvents: "box-none", children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.overlayTop, children: [(0, jsx_runtime_1.jsx)(react_native_reanimated_1.default.View, { style: [styles.assetNameBadge, selectionBadgeStyle], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.assetNameText, numberOfLines: 1, children: activeAssetName }) }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.overlayActions, children: [(0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.overlayButton, onPress: resetToAttachBone, children: (0, jsx_runtime_1.jsx)(vector_icons_1.Ionicons, { name: "locate-outline", size: 20, color: COLORS.textPrimary }) }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.overlayButton, onPress: () => {
793
- Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
794
- setShowPrecision((prev) => !prev);
795
- }, children: (0, jsx_runtime_1.jsx)(vector_icons_1.Ionicons, { name: "settings-outline", size: 20, color: showPrecision ? COLORS.accent : COLORS.textPrimary }) }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: [styles.overlayButton, styles.doneButton], onPress: () => {
796
- // Ensure pendingCommit is populated from current refs
797
- // (may not have been set if user only used PrecisionPanel, not gestures)
798
- if (activeAssetId) {
799
- const slotIdx = assetIdToSlot[activeAssetId];
800
- if (slotIdx !== undefined) {
801
- pendingCommit.current = {
802
- assetId: activeAssetId,
803
- slotIdx,
804
- pos: positionRefs.current[slotIdx],
805
- rot: rotationRefs.current[slotIdx],
806
- scl: scaleRefs.current[slotIdx],
807
- };
808
- }
809
- }
810
- commitActiveTransform();
811
- deselectAccessory();
812
- onDone();
813
- }, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.doneText, children: "Done" }) })] })] }), activeAssetId ? ((0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.overlayTapZone, onPress: deselectAccessory })) : ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.overlayTapZone, pointerEvents: "none" }))] }), showPrecision && activeAssetId && (0, jsx_runtime_1.jsx)(PrecisionPanel_1.default, {})] }));
814
- }
815
- exports.default = FilamentEditor;
816
- // ---------------------------------------------------------------------------
817
- // Styles
818
- // ---------------------------------------------------------------------------
819
- const styles = react_native_1.StyleSheet.create({
820
- container: {
821
- flex: 1,
822
- },
823
- sceneContainer: {
824
- flex: 1,
825
- },
826
- filamentView: {
827
- flex: 1,
828
- backgroundColor: COLORS.bg,
829
- },
830
- overlay: {
831
- ...react_native_1.StyleSheet.absoluteFillObject,
832
- justifyContent: 'space-between',
833
- },
834
- overlayTop: {
835
- flexDirection: 'row',
836
- justifyContent: 'space-between',
837
- alignItems: 'center',
838
- paddingHorizontal: 16,
839
- paddingTop: 12,
840
- },
841
- assetNameBadge: {
842
- backgroundColor: COLORS.card,
843
- borderRadius: 8,
844
- paddingHorizontal: 12,
845
- paddingVertical: 6,
846
- borderWidth: 1,
847
- borderColor: COLORS.border,
848
- maxWidth: 160,
849
- },
850
- assetNameText: {
851
- color: COLORS.textPrimary,
852
- fontSize: 13,
853
- fontWeight: '600',
854
- },
855
- overlayActions: {
856
- flexDirection: 'row',
857
- alignItems: 'center',
858
- gap: 8,
859
- },
860
- overlayButton: {
861
- backgroundColor: COLORS.card,
862
- borderRadius: 8,
863
- padding: 8,
864
- borderWidth: 1,
865
- borderColor: COLORS.border,
866
- },
867
- doneButton: {
868
- backgroundColor: COLORS.accent,
869
- borderColor: COLORS.accent,
870
- paddingHorizontal: 16,
871
- },
872
- doneText: {
873
- color: '#fff',
874
- fontSize: 14,
875
- fontWeight: '700',
876
- },
877
- overlayTapZone: {
878
- flex: 1,
879
- },
880
- });