@volter/editor-threejs 0.5.65 → 0.5.67

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 (112) hide show
  1. package/NOTICE +2 -0
  2. package/contributions/animation-mixers.service.ts +20 -0
  3. package/contributions/animation-timeline.utility.tsx +44 -0
  4. package/contributions/three-integration.service.ts +13 -0
  5. package/dist-node/serving.mjs +405 -0
  6. package/package.json +113 -5
  7. package/serving/animation-live-module.ts +70 -0
  8. package/serving/animation-stamp.ts +88 -0
  9. package/serving/index.ts +14 -0
  10. package/serving/model-import-conversion.ts +344 -0
  11. package/src/adapter/ingest/scene-capture.ts +1 -23
  12. package/src/adapter/renderer-config.ts +3 -4
  13. package/src/adapter/three-contract.ts +72 -0
  14. package/src/animation/live-mixers.ts +55 -0
  15. package/src/ecs/object-marks.ts +1 -1
  16. package/src/ecs/user-data.ts +0 -16
  17. package/src/host-hierarchy-objects.ts +31 -0
  18. package/src/kit/animation/three-clips-subject.ts +190 -0
  19. package/src/kit/asset-compare.ts +294 -0
  20. package/src/kit/asset-preview-command.ts +265 -0
  21. package/src/kit/asset-preview-framing.ts +357 -0
  22. package/src/kit/asset-preview.ts +2802 -0
  23. package/src/kit/asset-workflow/model-inspection.ts +830 -0
  24. package/src/kit/authoring/component-instance-root.ts +171 -0
  25. package/src/kit/authoring/design-time-settle.ts +343 -0
  26. package/src/kit/authoring/live-object-transform.ts +62 -0
  27. package/src/kit/authoring/object3d-document-session-registry.ts +154 -0
  28. package/src/kit/authoring/object3d-document-session.ts +1965 -0
  29. package/src/kit/authoring/object3d-gesture-controller.ts +113 -0
  30. package/src/kit/authoring/quarks-particle-systems.ts +19 -0
  31. package/src/kit/authoring/shell-viewport-policy.ts +48 -0
  32. package/src/kit/authoring/source-object3d-authoring-adapter.ts +526 -0
  33. package/src/kit/authoring/three-projection-core.ts +226 -0
  34. package/src/kit/authoring/viewport-pick-context.ts +39 -0
  35. package/src/kit/authoring/viewport-raycast.ts +240 -0
  36. package/src/kit/authoring/world-hidden-viewport.ts +95 -0
  37. package/src/kit/camera-authoring.ts +175 -0
  38. package/src/kit/components/CameraInfo.tsx +56 -0
  39. package/src/kit/components/InspectorObjectPreview.tsx +57 -0
  40. package/src/kit/components/Object3DDocumentToolbar.tsx +549 -0
  41. package/src/kit/components/Object3DDocumentViewport.tsx +58 -0
  42. package/src/kit/components/StageHost.tsx +2547 -0
  43. package/src/kit/components/StageOverlays.tsx +21 -0
  44. package/src/kit/components/StatsOverlay.tsx +78 -0
  45. package/src/kit/components/ToolObject3DPreview.tsx +39 -0
  46. package/src/kit/components/ViewportFurniture.tsx +655 -0
  47. package/src/kit/components/ViewportOverlay.tsx +215 -0
  48. package/src/kit/components/ViewportShadingMenu.tsx +340 -0
  49. package/src/kit/components/ViewportViewMenu.tsx +155 -0
  50. package/src/kit/components/asset-viewers/EntityModelDocument.tsx +121 -0
  51. package/src/kit/components/asset-viewers/EnvironmentAssetDocument.tsx +440 -0
  52. package/src/kit/components/asset-viewers/LiveModuleDocument.tsx +395 -0
  53. package/src/kit/components/asset-viewers/LutAssetDocument.tsx +444 -0
  54. package/src/kit/components/asset-viewers/ModelAssetDocument.tsx +105 -0
  55. package/src/kit/components/asset-viewers/Object3DPreview.tsx +356 -0
  56. package/src/kit/components/asset-viewers/QuarksAssetDocument.tsx +527 -0
  57. package/src/kit/components/asset-viewers/ShaderAssetDocument.tsx +743 -0
  58. package/src/kit/components/asset-viewers/three-asset-viewers.tsx +132 -0
  59. package/src/kit/components/object3d-contribution-surfaces.tsx +33 -0
  60. package/src/kit/components/stage-keyboard.tsx +40 -0
  61. package/src/kit/components/stage-overlay-set.tsx +105 -0
  62. package/src/kit/components/stage-presence-markers.ts +482 -0
  63. package/src/kit/components/stage-transform-chrome.ts +30 -0
  64. package/src/kit/components/stage-transform-tools.tsx +73 -0
  65. package/src/kit/components/stage-view-name.ts +30 -0
  66. package/src/kit/components/standard-viewport-dressing.ts +1042 -0
  67. package/src/kit/components/world-root-binding.ts +64 -0
  68. package/src/kit/constraint-helper.ts +338 -0
  69. package/src/kit/editor-shell-store.ts +814 -0
  70. package/src/kit/editor-viewport.ts +6621 -0
  71. package/src/kit/entity-lod.ts +31 -0
  72. package/src/kit/entity-object.ts +92 -0
  73. package/src/kit/hierarchy-mark-reader.ts +74 -0
  74. package/src/kit/instanced-presentation.ts +164 -0
  75. package/src/kit/live-module-source.ts +230 -0
  76. package/src/kit/model-thumbnail.ts +539 -0
  77. package/src/kit/play-camera-flight.ts +300 -0
  78. package/src/kit/projection/three.ts +898 -0
  79. package/src/kit/reflection-probe-helper.ts +142 -0
  80. package/src/kit/scene-document-viewport.ts +51 -0
  81. package/src/kit/scene-framing.ts +315 -0
  82. package/src/kit/scene-view-fog.ts +89 -0
  83. package/src/kit/spatial-handle-visuals.ts +332 -0
  84. package/src/kit/stories/three-story-model.ts +66 -0
  85. package/src/kit/three-canvas-render.ts +44 -0
  86. package/src/kit/three-hierarchy-row-media.ts +26 -0
  87. package/src/kit/three-inspection-media.ts +73 -0
  88. package/src/kit/three-integration.ts +86 -0
  89. package/src/kit/three-state.ts +33 -0
  90. package/src/kit/three-viewport/bone-selection-highlight.ts +119 -0
  91. package/src/kit/three-viewport/camera-fit.ts +41 -0
  92. package/src/kit/three-viewport/interactive-renderer.ts +132 -0
  93. package/src/kit/three-viewport/selection-brackets.ts +355 -0
  94. package/src/kit/three-viewport/selection-outline.ts +333 -0
  95. package/src/kit/three-viewport/skeleton-helper.ts +61 -0
  96. package/src/kit/three-viewport/source-color.ts +197 -0
  97. package/src/kit/three-viewport/studio-environment.ts +96 -0
  98. package/src/kit/trigger-volume-helper.ts +116 -0
  99. package/src/kit/viewport-actions.ts +128 -0
  100. package/src/kit/viewport-authoring-policy.ts +154 -0
  101. package/src/kit/viewport-commands.ts +318 -0
  102. package/src/kit/viewport-hotkeys.ts +119 -0
  103. package/src/kit/viewport-shading-boundary.ts +12 -0
  104. package/src/kit/viewport-status-facet.ts +53 -0
  105. package/src/object3d-contributions.ts +494 -0
  106. package/src/render/viewport-shading.ts +6 -2
  107. package/src/viewport/content-bounds.ts +38 -4
  108. package/src/viewport/environment.ts +16 -0
  109. package/src/viewport-api.ts +92 -0
  110. package/src/viewport-door.ts +237 -0
  111. package/src/animation/animation-clock.ts +0 -479
  112. package/src/animation/runtime-inspection.ts +0 -45
@@ -0,0 +1,440 @@
1
+ import { faCloudSun, faSliders } from '@fortawesome/free-solid-svg-icons';
2
+ import { NumberInput, themeVars } from '@volter/editor-sdk/widgets';
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import * as THREE from 'three';
5
+ import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
6
+ import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';
7
+ import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
8
+ import type { InspectionSection } from '@volter/editor-sdk/kit/inspection-model';
9
+ import { CONTRIBUTED_SECTION_ORDER, PROPERTIES_SECTION_ORDER } from '@volter/editor-sdk/kit/inspection-model';
10
+ import { AssetEditorShell } from '@volter/editor-sdk/kit/components/AssetEditorShell';
11
+ import { subscribeProjectAsset } from '@volter/editor-sdk/kit/components/asset-editor-persistence';
12
+
13
+ interface EnvironmentImage {
14
+ readonly texture: THREE.DataTexture;
15
+ readonly width: number;
16
+ readonly height: number;
17
+ readonly format: 'HDR' | 'EXR';
18
+ readonly panorama: boolean;
19
+ }
20
+
21
+ interface PreviewSettings {
22
+ readonly exposure: number;
23
+ readonly intensity: number;
24
+ readonly rotation: number;
25
+ readonly blur: number;
26
+ }
27
+
28
+ function basename(path: string): string {
29
+ return path.split('/').pop() ?? path;
30
+ }
31
+
32
+ function clamp(value: number, min: number, max: number): number {
33
+ return Math.min(max, Math.max(min, value));
34
+ }
35
+
36
+ function isExr(path: string): boolean {
37
+ return path.split(/[?#]/, 1)[0]!.toLowerCase().endsWith('.exr');
38
+ }
39
+
40
+ async function loadEnvironmentImage(path: string): Promise<EnvironmentImage> {
41
+ const format = isExr(path) ? 'EXR' : 'HDR';
42
+ const loader = format === 'EXR' ? new EXRLoader() : new HDRLoader();
43
+ const texture = await loader.loadAsync(path);
44
+ const image = texture.image as { width?: number; height?: number };
45
+ const width = image.width ?? 0;
46
+ const height = image.height ?? 0;
47
+ const panorama = width > 0 && height > 0 && Math.abs(width / height - 2) <= 0.04;
48
+ texture.mapping = THREE.EquirectangularReflectionMapping;
49
+ texture.needsUpdate = true;
50
+ return { texture, width, height, format, panorama };
51
+ }
52
+
53
+ function createPreviewScene(image: EnvironmentImage): {
54
+ readonly scene: THREE.Scene;
55
+ readonly camera: THREE.PerspectiveCamera;
56
+ readonly geometries: readonly THREE.BufferGeometry[];
57
+ readonly materials: readonly THREE.Material[];
58
+ } {
59
+ const scene = new THREE.Scene();
60
+ const camera = new THREE.PerspectiveCamera(38, 1, 0.05, 100);
61
+ camera.position.set(6.2, 3.4, 7.8);
62
+ camera.lookAt(0, 0.7, 0);
63
+ const geometries: THREE.BufferGeometry[] = [];
64
+ const materials: THREE.Material[] = [];
65
+
66
+ if (!image.panorama) {
67
+ scene.background = new THREE.Color('#11141b');
68
+ const aspect = image.width > 0 && image.height > 0 ? image.width / image.height : 1;
69
+ const geometry = new THREE.PlaneGeometry(
70
+ aspect >= 1 ? 4.8 : 4.8 * aspect,
71
+ aspect >= 1 ? 4.8 / aspect : 4.8,
72
+ );
73
+ const material = new THREE.MeshBasicMaterial({ map: image.texture, toneMapped: true });
74
+ geometries.push(geometry);
75
+ materials.push(material);
76
+ scene.add(new THREE.Mesh(geometry, material));
77
+ camera.position.set(0, 0, 6.5);
78
+ camera.lookAt(0, 0, 0);
79
+ return { scene, camera, geometries, materials };
80
+ }
81
+
82
+ scene.background = image.texture;
83
+ scene.environment = image.texture;
84
+
85
+ const addMesh = (
86
+ geometry: THREE.BufferGeometry,
87
+ material: THREE.Material,
88
+ position: readonly [number, number, number],
89
+ ) => {
90
+ geometries.push(geometry);
91
+ materials.push(material);
92
+ const mesh = new THREE.Mesh(geometry, material);
93
+ mesh.position.set(...position);
94
+ scene.add(mesh);
95
+ };
96
+
97
+ addMesh(
98
+ new THREE.SphereGeometry(1, 64, 40),
99
+ new THREE.MeshStandardMaterial({ color: '#d8dde5', roughness: 0.08, metalness: 1 }),
100
+ [-2.3, 1.15, 0],
101
+ );
102
+ addMesh(
103
+ new THREE.SphereGeometry(1, 64, 40),
104
+ new THREE.MeshStandardMaterial({ color: '#c97341', roughness: 0.32, metalness: 0.25 }),
105
+ [0, 1.15, 0],
106
+ );
107
+ addMesh(
108
+ new THREE.SphereGeometry(1, 64, 40),
109
+ new THREE.MeshStandardMaterial({ color: '#d7d5cf', roughness: 0.82, metalness: 0 }),
110
+ [2.3, 1.15, 0],
111
+ );
112
+ const floorGeometry = new THREE.CircleGeometry(5.7, 96);
113
+ const floorMaterial = new THREE.MeshStandardMaterial({
114
+ color: '#737881',
115
+ roughness: 0.58,
116
+ metalness: 0,
117
+ });
118
+ geometries.push(floorGeometry);
119
+ materials.push(floorMaterial);
120
+ const floor = new THREE.Mesh(floorGeometry, floorMaterial);
121
+ floor.rotation.x = -Math.PI / 2;
122
+ floor.position.y = 0.08;
123
+ scene.add(floor);
124
+
125
+ return { scene, camera, geometries, materials };
126
+ }
127
+
128
+ function EnvironmentPreview({
129
+ image,
130
+ settingsRef,
131
+ }: {
132
+ image: EnvironmentImage;
133
+ settingsRef: React.RefObject<PreviewSettings>;
134
+ }) {
135
+ const hostRef = useRef<HTMLDivElement>(null);
136
+
137
+ useEffect(() => {
138
+ const host = hostRef.current;
139
+ if (!host) return;
140
+ let disposed = false;
141
+ let frame = 0;
142
+ const canvas = document.createElement('canvas');
143
+ canvas.setAttribute('aria-label', `${image.format} environment preview`);
144
+ canvas.style.width = '100%';
145
+ canvas.style.height = '100%';
146
+ canvas.style.display = 'block';
147
+ host.replaceChildren(canvas);
148
+
149
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
150
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
151
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
152
+ const preview = createPreviewScene(image);
153
+ const controls = new OrbitControls(preview.camera, canvas);
154
+ controls.target.set(0, image.panorama ? 0.8 : 0, 0);
155
+ controls.enableDamping = true;
156
+ controls.enablePan = false;
157
+ controls.minDistance = image.panorama ? 3.8 : 4;
158
+ controls.maxDistance = image.panorama ? 14 : 10;
159
+ controls.update();
160
+
161
+ const size = { width: 1, height: 1 };
162
+ const draw = () => {
163
+ const settings = settingsRef.current;
164
+ renderer.toneMappingExposure = clamp(settings.exposure, 0.05, 8);
165
+ if (image.panorama) {
166
+ const rotation = THREE.MathUtils.degToRad(settings.rotation);
167
+ preview.scene.backgroundRotation.y = rotation;
168
+ preview.scene.environmentRotation.y = rotation;
169
+ preview.scene.backgroundBlurriness = clamp(settings.blur, 0, 1);
170
+ preview.scene.environmentIntensity = clamp(settings.intensity, 0, 8);
171
+ }
172
+ controls.update();
173
+ renderer.render(preview.scene, preview.camera);
174
+ };
175
+ const resize = () => {
176
+ const bounds = host.getBoundingClientRect();
177
+ size.width = Math.max(1, Math.floor(bounds.width));
178
+ size.height = Math.max(1, Math.floor(bounds.height));
179
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
180
+ renderer.setSize(size.width, size.height, false);
181
+ preview.camera.aspect = size.width / size.height;
182
+ preview.camera.updateProjectionMatrix();
183
+ draw();
184
+ };
185
+ resize();
186
+ const observer = new ResizeObserver(resize);
187
+ observer.observe(host);
188
+
189
+ const render = () => {
190
+ if (disposed) return;
191
+ draw();
192
+ frame = requestAnimationFrame(render);
193
+ };
194
+ frame = requestAnimationFrame(render);
195
+
196
+ return () => {
197
+ disposed = true;
198
+ cancelAnimationFrame(frame);
199
+ observer.disconnect();
200
+ controls.dispose();
201
+ for (const geometry of preview.geometries) geometry.dispose();
202
+ for (const material of preview.materials) material.dispose();
203
+ renderer.dispose();
204
+ canvas.remove();
205
+ };
206
+ }, [image, settingsRef]);
207
+
208
+ return (
209
+ <div
210
+ ref={hostRef}
211
+ data-testid="environment-preview"
212
+ style={{ width: '100%', height: '100%', minHeight: 260, background: '#11141b' }}
213
+ />
214
+ );
215
+ }
216
+
217
+ function PreviewControls({
218
+ panorama,
219
+ settings,
220
+ onChange,
221
+ }: {
222
+ panorama: boolean;
223
+ settings: PreviewSettings;
224
+ onChange: (next: PreviewSettings) => void;
225
+ }) {
226
+ return (
227
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, padding: 4 }}>
228
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11 }}>
229
+ Exposure
230
+ <NumberInput
231
+ value={settings.exposure}
232
+ onChange={(exposure) => onChange({ ...settings, exposure: clamp(exposure, 0.05, 8) })}
233
+ step={0.05}
234
+ precision={2}
235
+ />
236
+ </label>
237
+ {panorama && (
238
+ <>
239
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11 }}>
240
+ Lighting intensity
241
+ <NumberInput
242
+ value={settings.intensity}
243
+ onChange={(intensity) => onChange({ ...settings, intensity: clamp(intensity, 0, 8) })}
244
+ step={0.05}
245
+ precision={2}
246
+ />
247
+ </label>
248
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11 }}>
249
+ Rotation
250
+ <NumberInput
251
+ value={settings.rotation}
252
+ onChange={(rotation) => onChange({ ...settings, rotation: rotation % 360 })}
253
+ step={1}
254
+ precision={0}
255
+ />
256
+ </label>
257
+ <label style={{ display: 'flex', flexDirection: 'column', gap: 5, fontSize: 11 }}>
258
+ Background blur
259
+ <NumberInput
260
+ value={settings.blur}
261
+ onChange={(blur) => onChange({ ...settings, blur: clamp(blur, 0, 1) })}
262
+ step={0.05}
263
+ precision={2}
264
+ />
265
+ </label>
266
+ </>
267
+ )}
268
+ <div style={{ color: themeVars.content.muted, fontSize: 10, lineHeight: 1.45 }}>
269
+ Preview controls are session-only. Bind the image, orientation, and accepted intensity in
270
+ the owning scene source.
271
+ </div>
272
+ </div>
273
+ );
274
+ }
275
+
276
+ export function EnvironmentAssetDocument({
277
+ documentId,
278
+ assetPath,
279
+ active = true,
280
+ }: {
281
+ documentId: string;
282
+ assetPath: string;
283
+ active?: boolean;
284
+ }) {
285
+ const [revision, setRevision] = useState(0);
286
+ const [image, setImage] = useState<EnvironmentImage | null>(null);
287
+ const [loadError, setLoadError] = useState<string | null>(null);
288
+ const [settings, setSettings] = useState<PreviewSettings>({
289
+ exposure: 1,
290
+ intensity: 1,
291
+ rotation: 0,
292
+ blur: 0,
293
+ });
294
+ const settingsRef = useRef(settings);
295
+ settingsRef.current = settings;
296
+
297
+ useEffect(() => {
298
+ if (!active) return;
299
+ return subscribeProjectAsset(assetPath, () => setRevision((value) => value + 1));
300
+ }, [active, assetPath]);
301
+
302
+ useEffect(() => {
303
+ if (!active) return;
304
+ let cancelled = false;
305
+ let owned: EnvironmentImage | null = null;
306
+ setImage(null);
307
+ setLoadError(null);
308
+ void loadEnvironmentImage(assetPath).then(
309
+ (next) => {
310
+ owned = next;
311
+ if (cancelled) {
312
+ next.texture.dispose();
313
+ return;
314
+ }
315
+ setImage(next);
316
+ },
317
+ (cause) => {
318
+ if (!cancelled) setLoadError(cause instanceof Error ? cause.message : String(cause));
319
+ },
320
+ );
321
+ return () => {
322
+ cancelled = true;
323
+ owned?.texture.dispose();
324
+ };
325
+ }, [active, assetPath, revision]);
326
+
327
+ const sections = useMemo<InspectionSection[]>(
328
+ () => [
329
+ {
330
+ id: 'environment-image',
331
+ title: 'Environment Image',
332
+ icon: faCloudSun,
333
+ order: PROPERTIES_SECTION_ORDER,
334
+ body: {
335
+ kind: 'custom',
336
+ ...(image
337
+ ? {
338
+ data: {
339
+ format: image.format,
340
+ width: image.width,
341
+ height: image.height,
342
+ panorama: image.panorama,
343
+ },
344
+ }
345
+ : {}),
346
+ render: () => (
347
+ <div style={{ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '6px 10px' }}>
348
+ <span style={{ color: themeVars.content.muted }}>Format</span>
349
+ <span>{image?.format ?? '—'}</span>
350
+ <span style={{ color: themeVars.content.muted }}>Resolution</span>
351
+ <span>{image ? `${image.width} × ${image.height}` : '—'}</span>
352
+ <span style={{ color: themeVars.content.muted }}>Projection</span>
353
+ <span>{image?.panorama ? '2:1 equirectangular' : 'Flat image'}</span>
354
+ </div>
355
+ ),
356
+ },
357
+ },
358
+ {
359
+ id: 'environment-preview-settings',
360
+ title: 'Preview',
361
+ icon: faSliders,
362
+ order: CONTRIBUTED_SECTION_ORDER,
363
+ body: {
364
+ kind: 'custom',
365
+ data: { ...settings },
366
+ render: () => (
367
+ <PreviewControls
368
+ panorama={image?.panorama ?? false}
369
+ settings={settings}
370
+ onChange={setSettings}
371
+ />
372
+ ),
373
+ },
374
+ },
375
+ ],
376
+ [image, settings],
377
+ );
378
+
379
+ const title = basename(assetPath);
380
+ if (loadError) {
381
+ return (
382
+ <AssetEditorShell
383
+ documentId={documentId}
384
+ active={active}
385
+ type="environment image"
386
+ title={title}
387
+ status="HDR image unavailable"
388
+ >
389
+ <div role="alert" style={{ padding: 20, color: themeVars.semantic.danger }}>
390
+ {loadError}
391
+ </div>
392
+ </AssetEditorShell>
393
+ );
394
+ }
395
+
396
+ if (!image) {
397
+ return (
398
+ <AssetEditorShell
399
+ documentId={documentId}
400
+ active={active}
401
+ type="environment image"
402
+ title={title}
403
+ status="Loading HDR image"
404
+ >
405
+ <div style={{ padding: 20, color: themeVars.content.muted }}>Loading environment…</div>
406
+ </AssetEditorShell>
407
+ );
408
+ }
409
+
410
+ return (
411
+ <AssetEditorShell
412
+ documentId={documentId}
413
+ active={active}
414
+ type="environment image"
415
+ title={title}
416
+ sections={sections}
417
+ status={`${image.format} · ${image.width} × ${image.height}`}
418
+ fill
419
+ >
420
+ <div style={{ position: 'relative', width: '100%', height: '100%', minHeight: 0 }}>
421
+ <EnvironmentPreview image={image} settingsRef={settingsRef} />
422
+ <div
423
+ style={{
424
+ position: 'absolute',
425
+ left: 12,
426
+ bottom: 12,
427
+ padding: '4px 7px',
428
+ borderRadius: 4,
429
+ background: 'rgba(12,14,20,0.72)',
430
+ color: '#f2f4f8',
431
+ fontSize: 10,
432
+ pointerEvents: 'none',
433
+ }}
434
+ >
435
+ {image.panorama ? 'Drag to orbit · wheel to frame' : 'Non-2:1 image · flat preview'}
436
+ </div>
437
+ </div>
438
+ </AssetEditorShell>
439
+ );
440
+ }