@viamrobotics/test-widgets 0.6.0 → 0.6.1

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.
@@ -15,9 +15,11 @@
15
15
 
16
16
  import PCDWidget from '../pcd/pcd-widget.svelte'
17
17
  import ExportScreenshot from './export-screenshot.svelte'
18
+ import { getPanoCoverageFromXmp } from './get-pano-coverage-from-xmp'
18
19
  import { getSourceNames } from './get-source-names'
19
20
  import { getXmpJsonFromImageBytes, type XmpJson } from './get-xmp-json-from-image'
20
21
  import LiveOrPollingVideo from './live-or-polling-video.svelte'
22
+ import LiveThreeSixtyCameraView from './live-three-sixty-camera-view.svelte'
21
23
  import { pickImageForSource } from './pick-image-for-source'
22
24
  import PictureInPictureButton from './picture-in-picture-button.svelte'
23
25
  import ThreeSixtyCameraView from './three-sixty-camera-view.svelte'
@@ -58,16 +60,30 @@
58
60
  () => resourceName
59
61
  )
60
62
 
63
+ const isLive = $derived(refetchInterval.current === RefetchIntervals.LIVE)
64
+
61
65
  const imageQuery = createResourceQuery(
62
66
  client,
63
67
  'getImages',
64
68
  () => (selectedSource ? ([[selectedSource]] as [string[]]) : ([] as [])),
65
69
  () => ({
66
- enabled: isPlaying && refetchInterval.current !== RefetchIntervals.LIVE,
70
+ enabled: isPlaying && !isLive,
67
71
  refetchInterval: refetchInterval.current,
68
72
  })
69
73
  )
70
74
 
75
+ // The live WebRTC stream carries no XMP, so when live we fetch a single frame
76
+ // to learn the GPano crop geometry needed to render the stream on the right band.
77
+ const liveCoverageProbe = createResourceQuery(
78
+ client,
79
+ 'getImages',
80
+ () => (selectedSource ? ([[selectedSource]] as [string[]]) : ([] as [])),
81
+ () => ({
82
+ enabled: isPlaying && isLive,
83
+ refetchInterval: false,
84
+ })
85
+ )
86
+
71
87
  $effect(() => {
72
88
  if (sourceNames.length === 0 && imageQuery.data?.images) {
73
89
  const names = getSourceNames(imageQuery.data.images)
@@ -79,7 +95,7 @@
79
95
  })
80
96
 
81
97
  const xmpJson = $derived.by((): XmpJson | null => {
82
- const imageRecord = imageQuery.data?.images?.[0]
98
+ const imageRecord = isLive ? liveCoverageProbe.data?.images?.[0] : imageQuery.data?.images?.[0]
83
99
  const image = imageRecord?.image
84
100
  if (!image) {
85
101
  return null
@@ -88,9 +104,14 @@
88
104
  return getXmpJsonFromImageBytes(new Uint8Array(image), imageRecord.mimeType)
89
105
  })
90
106
 
91
- const is360EnabledImage = $derived.by((): boolean => {
92
- return xmpJson?.['viam:is360'] === 'true'
93
- })
107
+ // Covers both the full-sphere `viam:equirectangular` tag and GPano
108
+ // (Google Photo Sphere) cropped-area metadata. Non-null means the image
109
+ // can be rendered on a viewing sphere.
110
+ const panoCoverage = $derived.by(() => getPanoCoverageFromXmp(xmpJson))
111
+ // A GPano frame is a partial band, not a full 360° sphere.
112
+ const panoToggleLabel = $derived(
113
+ panoCoverage?.kind === 'gpano' ? 'Display as panorama' : 'Display as 360°'
114
+ )
94
115
 
95
116
  const pointcloudQuery = createResourceQuery(client, 'getPointCloud', () => ({
96
117
  enabled: isShowingPointcloud,
@@ -154,9 +175,9 @@
154
175
  </Select>
155
176
  </Label>
156
177
  {/if}
157
- {#if is360EnabledImage}
178
+ {#if panoCoverage}
158
179
  <Label>
159
- Display as 360°
180
+ {panoToggleLabel}
160
181
  <ToggleButtons
161
182
  slot="input"
162
183
  options={['On', 'Off']}
@@ -173,15 +194,29 @@
173
194
  <div class="grow">
174
195
  {#if isPlaying}
175
196
  {#if displayAs360}
176
- <Canvas>
177
- <ThreeSixtyCameraView data={imageQuery.data} />
178
- </Canvas>
197
+ {#if isLive}
198
+ <!-- renderMode="always" keeps the live VideoTexture advancing each frame -->
199
+ <Canvas renderMode="always">
200
+ <LiveThreeSixtyCameraView
201
+ {partID}
202
+ {resourceName}
203
+ coverage={panoCoverage}
204
+ />
205
+ </Canvas>
206
+ {:else}
207
+ <Canvas>
208
+ <ThreeSixtyCameraView
209
+ data={imageQuery.data}
210
+ coverage={panoCoverage}
211
+ />
212
+ </Canvas>
213
+ {/if}
179
214
  {:else}
180
215
  <LiveOrPollingVideo
181
216
  {partID}
182
217
  {resourceName}
183
218
  showResolutionOptions
184
- isLive={refetchInterval.current === RefetchIntervals.LIVE}
219
+ {isLive}
185
220
  data={imageQuery.data}
186
221
  error={imageQuery.error}
187
222
  isLoading={imageQuery.isLoading}
@@ -0,0 +1,31 @@
1
+ import type { XmpJson } from './get-xmp-json-from-image';
2
+ /**
3
+ * Angular extent of a (partial) sphere, in Three.js `SphereGeometry` terms. The
4
+ * `kind` discriminator records which metadata form produced it so the UI can pick
5
+ * a matching label; the renderer only consumes the angles.
6
+ */
7
+ export interface PanoCoverage {
8
+ kind: 'equirectangular' | 'gpano';
9
+ /** Azimuth (longitude) start, radians. */
10
+ phiStart: number;
11
+ /** Azimuth span, radians (full sphere = 2π). */
12
+ phiLength: number;
13
+ /** Polar start from the +Y north pole, radians. */
14
+ thetaStart: number;
15
+ /** Polar span, radians (full sphere = π). */
16
+ thetaLength: number;
17
+ }
18
+ /**
19
+ * Resolve how an image's pixels map onto a viewing sphere from its XMP metadata.
20
+ *
21
+ * - `viam:equirectangular="true"` → a full 360°×180° sphere.
22
+ * - `GPano:ProjectionType="equirectangular"` → the cropped sub-rectangle described
23
+ * by the GPano `FullPano*`/`CroppedArea*` pixel fields, mapped to a partial sphere
24
+ * (e.g. an equatorial band with empty poles). Malformed/zero fields fall back to
25
+ * a full sphere rather than producing NaN geometry.
26
+ *
27
+ * @param xmp - Parsed XMP attributes, or `null` when the image has none.
28
+ * @returns The sphere coverage, or `null` if the image is not a sphere-displayable
29
+ * equirectangular panorama.
30
+ */
31
+ export declare const getPanoCoverageFromXmp: (xmp: XmpJson | null) => PanoCoverage | null;
@@ -0,0 +1,56 @@
1
+ const TAU = Math.PI * 2;
2
+ const FULL_ANGLES = {
3
+ phiStart: 0,
4
+ phiLength: TAU,
5
+ thetaStart: 0,
6
+ thetaLength: Math.PI,
7
+ };
8
+ /**
9
+ * Resolve how an image's pixels map onto a viewing sphere from its XMP metadata.
10
+ *
11
+ * - `viam:equirectangular="true"` → a full 360°×180° sphere.
12
+ * - `GPano:ProjectionType="equirectangular"` → the cropped sub-rectangle described
13
+ * by the GPano `FullPano*`/`CroppedArea*` pixel fields, mapped to a partial sphere
14
+ * (e.g. an equatorial band with empty poles). Malformed/zero fields fall back to
15
+ * a full sphere rather than producing NaN geometry.
16
+ *
17
+ * @param xmp - Parsed XMP attributes, or `null` when the image has none.
18
+ * @returns The sphere coverage, or `null` if the image is not a sphere-displayable
19
+ * equirectangular panorama.
20
+ */
21
+ export const getPanoCoverageFromXmp = (xmp) => {
22
+ if (!xmp) {
23
+ return null;
24
+ }
25
+ if (xmp['viam:equirectangular'] === 'true') {
26
+ return { kind: 'equirectangular', ...FULL_ANGLES };
27
+ }
28
+ if (xmp['GPano:ProjectionType'] !== 'equirectangular') {
29
+ return null;
30
+ }
31
+ const num = (key) => Number(xmp[key]);
32
+ const fullWidth = num('GPano:FullPanoWidthPixels');
33
+ const fullHeight = num('GPano:FullPanoHeightPixels');
34
+ // Malformed/zero dims → full sphere rather than NaN geometry.
35
+ if (!(fullWidth > 0) || !(fullHeight > 0)) {
36
+ return { kind: 'gpano', ...FULL_ANGLES };
37
+ }
38
+ const croppedWidth = num('GPano:CroppedAreaImageWidthPixels');
39
+ const croppedHeight = num('GPano:CroppedAreaImageHeightPixels');
40
+ // Malformed/zero cropped dims → full sphere rather than zero-area geometry.
41
+ if (!(croppedWidth > 0) || !(croppedHeight > 0)) {
42
+ return { kind: 'gpano', ...FULL_ANGLES };
43
+ }
44
+ const coverage = {
45
+ kind: 'gpano',
46
+ phiStart: TAU * (num('GPano:CroppedAreaLeftPixels') / fullWidth),
47
+ phiLength: TAU * (croppedWidth / fullWidth),
48
+ thetaStart: Math.PI * (num('GPano:CroppedAreaTopPixels') / fullHeight),
49
+ thetaLength: Math.PI * (croppedHeight / fullHeight),
50
+ };
51
+ // Any non-finite cropped field → full sphere fallback.
52
+ const angles = [coverage.phiStart, coverage.phiLength, coverage.thetaStart, coverage.thetaLength];
53
+ return angles.every((element) => Number.isFinite(element))
54
+ ? coverage
55
+ : { kind: 'gpano', ...FULL_ANGLES };
56
+ };
@@ -0,0 +1,61 @@
1
+ <script lang="ts">
2
+ import { useThrelte } from '@threlte/core'
3
+ import { createStreamClient } from '@viamrobotics/svelte-sdk'
4
+ import { VideoTexture } from 'three'
5
+
6
+ import type { PanoCoverage } from './get-pano-coverage-from-xmp'
7
+
8
+ import PanoSphereScene from './pano-sphere-scene.svelte'
9
+
10
+ interface Props {
11
+ partID: string
12
+ resourceName: string
13
+ coverage?: PanoCoverage | null
14
+ }
15
+
16
+ const { partID, resourceName, coverage = null }: Props = $props()
17
+
18
+ const stream = createStreamClient(
19
+ () => partID,
20
+ () => resourceName
21
+ )
22
+ const { renderer } = useThrelte()
23
+
24
+ // A muted, inline, autoplaying <video> backs the VideoTexture. Mirrors the
25
+ // element setup in live-or-polling-video.svelte; created up front like the
26
+ // other camera widgets create their <img>/<canvas> elements.
27
+ const video = document.createElement('video')
28
+ video.muted = true
29
+ video.autoplay = true
30
+ video.playsInline = true
31
+
32
+ let map = $state.raw<VideoTexture>()
33
+
34
+ $effect(() => {
35
+ const mediaStream = stream.mediaStream
36
+ if (!mediaStream) {
37
+ video.srcObject = null
38
+ map = undefined
39
+ return
40
+ }
41
+
42
+ video.srcObject = mediaStream
43
+ void video.play()
44
+
45
+ const texture = new VideoTexture(video)
46
+ texture.colorSpace = renderer.outputColorSpace
47
+ map = texture
48
+
49
+ return () => {
50
+ texture.dispose()
51
+ video.pause()
52
+ video.srcObject = null
53
+ map = undefined
54
+ }
55
+ })
56
+ </script>
57
+
58
+ <PanoSphereScene
59
+ {coverage}
60
+ {map}
61
+ />
@@ -0,0 +1,9 @@
1
+ import type { PanoCoverage } from './get-pano-coverage-from-xmp';
2
+ interface Props {
3
+ partID: string;
4
+ resourceName: string;
5
+ coverage?: PanoCoverage | null;
6
+ }
7
+ declare const LiveThreeSixtyCameraView: import("svelte").Component<Props, {}, "">;
8
+ type LiveThreeSixtyCameraView = ReturnType<typeof LiveThreeSixtyCameraView>;
9
+ export default LiveThreeSixtyCameraView;
@@ -0,0 +1,54 @@
1
+ <script lang="ts">
2
+ import { T } from '@threlte/core'
3
+ import { OrbitControls } from '@threlte/extras'
4
+ import { BackSide, type Texture } from 'three'
5
+
6
+ import type { PanoCoverage } from './get-pano-coverage-from-xmp'
7
+
8
+ interface Props {
9
+ coverage?: PanoCoverage | null
10
+ /** Texture to paint on the inside of the sphere (image frame or live video). */
11
+ map?: Texture
12
+ }
13
+
14
+ const { coverage = null, map }: Props = $props()
15
+
16
+ // Default to a full sphere so a missing coverage renders the original 360° view.
17
+ // `kind` is irrelevant to geometry; only the angles are consumed below.
18
+ const sphere = $derived(
19
+ coverage ?? { phiStart: 0, phiLength: Math.PI * 2, thetaStart: 0, thetaLength: Math.PI }
20
+ )
21
+
22
+ // A band that covers less than the full pole-to-pole sweep is a horizontal-only
23
+ // view: fitting the camera's vertical FOV to the band removes the empty sphere
24
+ // above and below it, and locking the vertical tilt keeps the user from swinging
25
+ // toward those empty caps. A full sphere keeps the original free 75° view.
26
+ const isPartialBand = $derived(sphere.thetaLength < Math.PI - 1e-3)
27
+ const verticalCenter = $derived(sphere.thetaStart + sphere.thetaLength / 2)
28
+ const fov = $derived(isPartialBand ? (sphere.thetaLength * 180) / Math.PI : 75)
29
+ </script>
30
+
31
+ <T.PerspectiveCamera
32
+ makeDefault
33
+ position={[0, 0, 0.1]}
34
+ {fov}
35
+ >
36
+ <OrbitControls
37
+ enableZoom={true}
38
+ enablePan={false}
39
+ minPolarAngle={isPartialBand ? verticalCenter : 0}
40
+ maxPolarAngle={isPartialBand ? verticalCenter : Math.PI}
41
+ />
42
+ </T.PerspectiveCamera>
43
+
44
+ {#if map}
45
+ <T.Mesh scale={[-1, 1, 1]}>
46
+ <T.SphereGeometry
47
+ args={[500, 60, 40, sphere.phiStart, sphere.phiLength, sphere.thetaStart, sphere.thetaLength]}
48
+ />
49
+ <T.MeshBasicMaterial
50
+ {map}
51
+ side={BackSide}
52
+ />
53
+ </T.Mesh>
54
+ {/if}
@@ -0,0 +1,10 @@
1
+ import { type Texture } from 'three';
2
+ import type { PanoCoverage } from './get-pano-coverage-from-xmp';
3
+ interface Props {
4
+ coverage?: PanoCoverage | null;
5
+ /** Texture to paint on the inside of the sphere (image frame or live video). */
6
+ map?: Texture;
7
+ }
8
+ declare const PanoSphereScene: import("svelte").Component<Props, {}, "">;
9
+ type PanoSphereScene = ReturnType<typeof PanoSphereScene>;
10
+ export default PanoSphereScene;
@@ -1,19 +1,22 @@
1
1
  <script lang="ts">
2
2
  import type { QueryObserverResult } from '@tanstack/svelte-query'
3
- import type { OrbitControls as OrbitControlsType } from 'three/examples/jsm/controls/OrbitControls.js'
3
+ import type { Texture } from 'three'
4
4
 
5
- import { T } from '@threlte/core'
6
- import { OrbitControls, useTexture } from '@threlte/extras'
5
+ import { useTexture } from '@threlte/extras'
7
6
  import { CameraClient } from '@viamrobotics/sdk'
8
- import { BackSide } from 'three'
9
7
 
10
- const {
11
- data,
12
- }: { data: QueryObserverResult<Awaited<ReturnType<CameraClient['getImages']>>>['data'] } =
13
- $props()
8
+ import type { PanoCoverage } from './get-pano-coverage-from-xmp'
9
+
10
+ import PanoSphereScene from './pano-sphere-scene.svelte'
11
+
12
+ interface Props {
13
+ data: QueryObserverResult<Awaited<ReturnType<CameraClient['getImages']>>>['data']
14
+ coverage?: PanoCoverage | null
15
+ }
16
+
17
+ const { data, coverage = null }: Props = $props()
14
18
 
15
19
  let imageUrl = $state.raw('')
16
- let controlsRef = $state<OrbitControlsType>()
17
20
 
18
21
  $effect(() => {
19
22
  const imageRecord = data?.images?.[0]
@@ -23,47 +26,34 @@
23
26
  return
24
27
  }
25
28
 
26
- const imageBytes = new Uint8Array(image)
27
- const imageBlob = new Blob([imageBytes], {
29
+ const imageBlob = new Blob([new Uint8Array(image)], {
28
30
  type: imageRecord.mimeType || 'image/jpeg',
29
31
  })
30
32
  const url = URL.createObjectURL(imageBlob)
31
33
  imageUrl = url
32
34
 
33
- return () => {
34
- URL.revokeObjectURL(url)
35
- }
35
+ return () => URL.revokeObjectURL(url)
36
36
  })
37
37
 
38
- const texture = $derived.by(() => (imageUrl ? useTexture(imageUrl) : null))
39
- </script>
40
-
41
- <T.PerspectiveCamera
42
- makeDefault
43
- position={[0, 0, 0.1]}
44
- fov={75}
45
- >
46
- <OrbitControls
47
- bind:ref={controlsRef}
48
- enableZoom={true}
49
- enablePan={false}
50
- />
51
- </T.PerspectiveCamera>
38
+ // `useTexture` returns a store that resolves to the decoded texture (with the
39
+ // renderer's color space already applied). Mirror its value into reactive state so
40
+ // the sphere scene can render the camera even before the first frame loads, and so
41
+ // `map` clears when the source goes away.
42
+ const textureStore = $derived(imageUrl ? useTexture(imageUrl) : undefined)
43
+ let map = $state.raw<Texture>()
44
+ $effect(() => {
45
+ if (!textureStore) {
46
+ map = undefined
47
+ return
48
+ }
52
49
 
53
- {#if $texture}
54
- {#await texture then map}
55
- <T.Mesh scale={[-1, 1, 1]}>
56
- <T.SphereGeometry args={[500, 60, 40]} />
57
- <T.MeshBasicMaterial
58
- {map}
59
- side={BackSide}
60
- />
61
- </T.Mesh>
62
- {/await}
63
- {/if}
50
+ return textureStore.subscribe((value) => {
51
+ map = value ?? undefined
52
+ })
53
+ })
54
+ </script>
64
55
 
65
- <T.AmbientLight intensity={0.5} />
66
- <T.DirectionalLight
67
- position={[5, 5, 5]}
68
- intensity={1}
56
+ <PanoSphereScene
57
+ {coverage}
58
+ {map}
69
59
  />
@@ -1,8 +1,10 @@
1
1
  import type { QueryObserverResult } from '@tanstack/svelte-query';
2
2
  import { CameraClient } from '@viamrobotics/sdk';
3
- type $$ComponentProps = {
3
+ import type { PanoCoverage } from './get-pano-coverage-from-xmp';
4
+ interface Props {
4
5
  data: QueryObserverResult<Awaited<ReturnType<CameraClient['getImages']>>>['data'];
5
- };
6
- declare const ThreeSixtyCameraView: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ coverage?: PanoCoverage | null;
7
+ }
8
+ declare const ThreeSixtyCameraView: import("svelte").Component<Props, {}, "">;
7
9
  type ThreeSixtyCameraView = ReturnType<typeof ThreeSixtyCameraView>;
8
10
  export default ThreeSixtyCameraView;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viamrobotics/test-widgets",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "files": [