react-native-image-stitcher 0.15.1 → 0.16.0
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/CHANGELOG.md +147 -1
- package/README.md +116 -5
- package/android/src/main/cpp/image_stitcher_jni.cpp +107 -11
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +223 -1
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +87 -30
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +1 -1
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +14 -8
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +39 -1
- package/cpp/crop_quad.cpp +162 -0
- package/cpp/crop_quad.hpp +163 -0
- package/cpp/stitcher.cpp +651 -55
- package/cpp/stitcher.hpp +10 -0
- package/cpp/warp_guard.hpp +212 -0
- package/dist/camera/Camera.d.ts +196 -12
- package/dist/camera/Camera.js +629 -35
- package/dist/camera/CameraView.js +62 -5
- package/dist/camera/CaptureCountdownOverlay.d.ts +70 -0
- package/dist/camera/CaptureCountdownOverlay.js +239 -0
- package/dist/camera/CaptureFrameCounterOverlay.d.ts +58 -0
- package/dist/camera/CaptureFrameCounterOverlay.js +142 -0
- package/dist/camera/CaptureMemoryPill.d.ts +9 -1
- package/dist/camera/CaptureMemoryPill.js +3 -3
- package/dist/camera/CapturePreview.js +2 -1
- package/dist/camera/CaptureStatusOverlay.d.ts +11 -4
- package/dist/camera/CaptureStatusOverlay.js +22 -5
- package/dist/camera/CaptureThumbnailStrip.js +2 -1
- package/dist/camera/LateralMotionModal.d.ts +85 -0
- package/dist/camera/LateralMotionModal.js +134 -0
- package/dist/camera/PanHowToOverlay.d.ts +76 -0
- package/dist/camera/PanHowToOverlay.js +222 -0
- package/dist/camera/PanoramaSettings.d.ts +8 -6
- package/dist/camera/PanoramaSettings.js +26 -5
- package/dist/camera/PanoramaSettingsModal.js +4 -4
- package/dist/camera/RectCropPreview.d.ts +161 -0
- package/dist/camera/RectCropPreview.js +480 -0
- package/dist/camera/RotateToLandscapePrompt.d.ts +87 -0
- package/dist/camera/RotateToLandscapePrompt.js +138 -0
- package/dist/camera/buildPanoramaInitialSettings.d.ts +19 -2
- package/dist/camera/buildPanoramaInitialSettings.js +9 -0
- package/dist/camera/cameraErrorMessages.d.ts +30 -1
- package/dist/camera/cameraErrorMessages.js +26 -10
- package/dist/camera/cameraGuidanceCopy.d.ts +87 -0
- package/dist/camera/cameraGuidanceCopy.js +80 -0
- package/dist/camera/captureCountdown.d.ts +52 -0
- package/dist/camera/captureCountdown.js +76 -0
- package/dist/camera/captureWarnings.d.ts +90 -0
- package/dist/camera/captureWarnings.js +108 -0
- package/dist/camera/classifyStitchError.d.ts +30 -0
- package/dist/camera/classifyStitchError.js +42 -0
- package/dist/camera/cropGeometry.d.ts +136 -0
- package/dist/camera/cropGeometry.js +223 -0
- package/dist/camera/displayDecodeImageProps.d.ts +25 -0
- package/dist/camera/displayDecodeImageProps.js +29 -0
- package/dist/camera/guidanceGraphics.d.ts +58 -0
- package/dist/camera/guidanceGraphics.js +280 -0
- package/dist/camera/guidanceTokens.d.ts +54 -0
- package/dist/camera/guidanceTokens.js +58 -0
- package/dist/camera/panModeGate.d.ts +54 -0
- package/dist/camera/panModeGate.js +62 -0
- package/dist/camera/pickCaptureFormat.d.ts +71 -0
- package/dist/camera/pickCaptureFormat.js +85 -0
- package/dist/camera/stitchDebugInfo.d.ts +27 -0
- package/dist/camera/stitchDebugInfo.js +55 -0
- package/dist/camera/usePanMotion.d.ts +250 -0
- package/dist/camera/usePanMotion.js +451 -0
- package/dist/index.d.ts +24 -3
- package/dist/index.js +33 -2
- package/dist/stitching/computeInscribedRect.d.ts +40 -0
- package/dist/stitching/computeInscribedRect.js +55 -0
- package/dist/stitching/cropQuad.d.ts +78 -0
- package/dist/stitching/cropQuad.js +116 -0
- package/dist/stitching/incremental.d.ts +45 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +56 -8
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +2 -2
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +48 -5
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +27 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +191 -7
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +25 -1
- package/ios/Sources/RNImageStitcher/Stitcher.swift +34 -1
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +5 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +56 -0
- package/package.json +5 -1
- package/src/camera/Camera.tsx +994 -47
- package/src/camera/CameraView.tsx +75 -5
- package/src/camera/CaptureCountdownOverlay.tsx +272 -0
- package/src/camera/CaptureFrameCounterOverlay.tsx +183 -0
- package/src/camera/CaptureMemoryPill.tsx +17 -3
- package/src/camera/CapturePreview.tsx +5 -0
- package/src/camera/CaptureStatusOverlay.tsx +35 -7
- package/src/camera/CaptureThumbnailStrip.tsx +4 -0
- package/src/camera/LateralMotionModal.tsx +199 -0
- package/src/camera/PanHowToOverlay.tsx +246 -0
- package/src/camera/PanoramaSettings.ts +34 -11
- package/src/camera/PanoramaSettingsModal.tsx +4 -4
- package/src/camera/RectCropPreview.tsx +820 -0
- package/src/camera/RotateToLandscapePrompt.tsx +188 -0
- package/src/camera/buildPanoramaInitialSettings.ts +30 -1
- package/src/camera/cameraErrorMessages.ts +39 -2
- package/src/camera/cameraGuidanceCopy.ts +145 -0
- package/src/camera/captureCountdown.ts +83 -0
- package/src/camera/captureWarnings.ts +190 -0
- package/src/camera/classifyStitchError.ts +68 -0
- package/src/camera/cropGeometry.ts +268 -0
- package/src/camera/displayDecodeImageProps.ts +25 -0
- package/src/camera/guidanceGraphics.tsx +347 -0
- package/src/camera/guidanceTokens.ts +57 -0
- package/src/camera/panModeGate.ts +81 -0
- package/src/camera/pickCaptureFormat.ts +130 -0
- package/src/camera/stitchDebugInfo.ts +71 -0
- package/src/camera/usePanMotion.ts +667 -0
- package/src/index.ts +66 -3
- package/src/stitching/computeInscribedRect.ts +81 -0
- package/src/stitching/cropQuad.ts +167 -0
- package/src/stitching/incremental.ts +45 -0
- package/cpp/tests/CMakeLists.txt +0 -104
- package/cpp/tests/README.md +0 -86
- package/cpp/tests/keyframe_timebudget_test.cpp +0 -65
- package/cpp/tests/pose_test.cpp +0 -74
- package/cpp/tests/stitcher_frame_data_test.cpp +0 -132
- package/cpp/tests/stubs/jsi/jsi.h +0 -33
- package/cpp/tests/stubs/react-native-worklets-core/WKTJsiWorklet.h +0 -34
- package/cpp/tests/warp_guard_test.cpp +0 -48
- package/src/camera/__tests__/PanoramaSettingsBridge.test.ts +0 -190
- package/src/camera/__tests__/bandThumbRotation.test.ts +0 -120
- package/src/camera/__tests__/buildPanoramaInitialSettings.test.ts +0 -160
- package/src/camera/__tests__/cameraErrorMessages.test.ts +0 -76
- package/src/camera/__tests__/homeIndicatorEdge.test.ts +0 -116
- package/src/camera/__tests__/lowMemDevice.test.ts +0 -52
- package/src/camera/__tests__/selectCaptureDevice.test.ts +0 -210
- package/src/camera/__tests__/useContentRotation.test.ts +0 -89
- package/src/camera/__tests__/useOrientationDrift.test.ts +0 -169
- package/src/stitching/__tests__/subscribeIncrementalState.refine.test.ts +0 -276
- package/src/stitching/__tests__/useStitcherWorklet.test.ts +0 -202
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* guidanceGraphics — code-drawn replacements for the two authored guidance
|
|
4
|
+
* GIFs (rotate-to-landscape, pan-capture). Built from pure React-Native
|
|
5
|
+
* core `View` + `Animated` primitives — NO `react-native-svg`, NO bundled
|
|
6
|
+
* image assets — so the library keeps its "zero extra native deps for
|
|
7
|
+
* guidance" contract (see `RectCropPreview`) AND no longer needs the host
|
|
8
|
+
* to add Fresco's `animated-gif` module on Android just to make the
|
|
9
|
+
* coach-marks move.
|
|
10
|
+
*
|
|
11
|
+
* Why not GIFs: the authored GIFs were 280 px sources shown at 240 dp;
|
|
12
|
+
* on a ~2.6×-density phone that 240 dp is ~630 physical px, so the 280 px
|
|
13
|
+
* source was up-scaled ~2.25× → visibly pixelated. A 256-colour GIF also
|
|
14
|
+
* bands. These vector-ish primitives are resolution-independent (they're
|
|
15
|
+
* just borders + transforms the GPU rasterises at native density) and fully
|
|
16
|
+
* themeable via `GUIDANCE_TOKENS`.
|
|
17
|
+
*
|
|
18
|
+
* Both graphics:
|
|
19
|
+
* • run a single `Animated.loop` on the NATIVE driver (transform/opacity
|
|
20
|
+
* only) so the loop is off the JS thread;
|
|
21
|
+
* • take a `playing` flag — the host renders them only while `visible`,
|
|
22
|
+
* but we still gate the loop so a mounted-but-paused graphic costs
|
|
23
|
+
* nothing;
|
|
24
|
+
* • scale every dimension off a single `size` (defaults to the shared
|
|
25
|
+
* `GUIDANCE_TOKENS.graphicSize`) so callers can resize without restyle.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import React, { useEffect, useRef } from 'react';
|
|
29
|
+
import {
|
|
30
|
+
Animated,
|
|
31
|
+
Easing,
|
|
32
|
+
StyleSheet,
|
|
33
|
+
View,
|
|
34
|
+
type StyleProp,
|
|
35
|
+
type ViewStyle,
|
|
36
|
+
} from 'react-native';
|
|
37
|
+
|
|
38
|
+
import { GUIDANCE_TOKENS } from './guidanceTokens';
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
const DEFAULT_SIZE = GUIDANCE_TOKENS.graphicSize;
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
/** Pan direction the pan-graphic should animate (mirrors PanHowToOverlay). */
|
|
45
|
+
export type PanGraphicDirection = 'down' | 'right';
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
export interface GuidanceGraphicProps {
|
|
49
|
+
/** Canvas square size in px. Defaults to `GUIDANCE_TOKENS.graphicSize`. */
|
|
50
|
+
size?: number;
|
|
51
|
+
/** Run the animation loop. `false` parks the value at rest. */
|
|
52
|
+
playing?: boolean;
|
|
53
|
+
/** Outer style passthrough. */
|
|
54
|
+
style?: StyleProp<ViewStyle>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A white rounded-rectangle "phone" outline with a small camera dot on its
|
|
60
|
+
* top short edge. The dot makes the device's up-axis legible, so when the
|
|
61
|
+
* rotate graphic turns the body the rotation reads unambiguously. Children
|
|
62
|
+
* (e.g. the pan sweep band) render over the screen area.
|
|
63
|
+
*/
|
|
64
|
+
function PhoneBody({
|
|
65
|
+
width,
|
|
66
|
+
height,
|
|
67
|
+
children,
|
|
68
|
+
style,
|
|
69
|
+
}: {
|
|
70
|
+
width: number;
|
|
71
|
+
height: number;
|
|
72
|
+
children?: React.ReactNode;
|
|
73
|
+
style?: StyleProp<ViewStyle>;
|
|
74
|
+
}): React.JSX.Element {
|
|
75
|
+
const radius = Math.min(width, height) * 0.16;
|
|
76
|
+
const short = Math.min(width, height);
|
|
77
|
+
const dotSize = Math.max(4, short * 0.09);
|
|
78
|
+
const inset = Math.max(4, short * 0.06);
|
|
79
|
+
// The front-facing camera always sits on a SHORT edge: top-centre for a
|
|
80
|
+
// tall (portrait) body, side-centre for a wide (landscape) body. (Was
|
|
81
|
+
// top-centre unconditionally, which put the dot mid-LONG-edge on a
|
|
82
|
+
// landscape body.)
|
|
83
|
+
const isWide = width > height;
|
|
84
|
+
const dotPos: ViewStyle = isWide
|
|
85
|
+
? { left: inset, top: height / 2 - dotSize / 2 }
|
|
86
|
+
: { top: inset, left: width / 2 - dotSize / 2 };
|
|
87
|
+
return (
|
|
88
|
+
<View
|
|
89
|
+
style={[
|
|
90
|
+
{
|
|
91
|
+
width,
|
|
92
|
+
height,
|
|
93
|
+
borderRadius: radius,
|
|
94
|
+
borderWidth: Math.max(2, width * 0.03),
|
|
95
|
+
borderColor: GUIDANCE_TOKENS.white,
|
|
96
|
+
},
|
|
97
|
+
styles.phoneBody,
|
|
98
|
+
style,
|
|
99
|
+
]}
|
|
100
|
+
>
|
|
101
|
+
<View
|
|
102
|
+
style={[
|
|
103
|
+
styles.cameraDot,
|
|
104
|
+
{ width: dotSize, height: dotSize, borderRadius: dotSize / 2 },
|
|
105
|
+
dotPos,
|
|
106
|
+
]}
|
|
107
|
+
/>
|
|
108
|
+
{children}
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* RotatePhoneGraphic — a portrait phone outline that rotates 0°→90°→0°
|
|
116
|
+
* (portrait → landscape → portrait) on a loop, riding a faint amber guide
|
|
117
|
+
* ring with a clockwise arrowhead, demonstrating the "rotate to landscape"
|
|
118
|
+
* gesture. Replaces `rotate-to-landscape.gif`.
|
|
119
|
+
*/
|
|
120
|
+
export function RotatePhoneGraphic({
|
|
121
|
+
size = DEFAULT_SIZE,
|
|
122
|
+
playing = true,
|
|
123
|
+
style,
|
|
124
|
+
target = 'landscape',
|
|
125
|
+
}: GuidanceGraphicProps & {
|
|
126
|
+
/** Orientation to rotate TO: 'landscape' (default) or 'portrait'. */
|
|
127
|
+
target?: 'landscape' | 'portrait';
|
|
128
|
+
}): React.JSX.Element {
|
|
129
|
+
// Single 0→1 loop value drives a ONE-WAY demonstration: hold the START
|
|
130
|
+
// orientation, rotate to the TARGET, hold, then fade out + reset (the
|
|
131
|
+
// reverse rotation happens while invisible). This avoids the symmetric
|
|
132
|
+
// oscillation, which dwelt at the target and read as "starts at target,
|
|
133
|
+
// rotates away" — i.e. backwards.
|
|
134
|
+
const t = useRef(new Animated.Value(0)).current;
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!playing) {
|
|
138
|
+
t.setValue(0);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const loop = Animated.loop(
|
|
142
|
+
Animated.timing(t, {
|
|
143
|
+
toValue: 1,
|
|
144
|
+
duration: 2200,
|
|
145
|
+
easing: Easing.inOut(Easing.cubic),
|
|
146
|
+
useNativeDriver: true,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
loop.start();
|
|
150
|
+
return () => loop.stop();
|
|
151
|
+
}, [playing, t]);
|
|
152
|
+
|
|
153
|
+
// To-landscape: start portrait (tall), rotate anticlockwise to landscape.
|
|
154
|
+
// To-portrait: start landscape (wide), rotate clockwise to stand upright.
|
|
155
|
+
const toLandscape = target === 'landscape';
|
|
156
|
+
const targetDeg = toLandscape ? '-90deg' : '90deg';
|
|
157
|
+
// Hold START (0°) → rotate to TARGET → hold TARGET.
|
|
158
|
+
const rotate = t.interpolate({
|
|
159
|
+
inputRange: [0, 0.18, 0.62, 1],
|
|
160
|
+
outputRange: ['0deg', '0deg', targetDeg, targetDeg],
|
|
161
|
+
});
|
|
162
|
+
// Fade in at START, hold through the rotation, fade out at TARGET so the
|
|
163
|
+
// invisible reset (target→start on loop) is never seen.
|
|
164
|
+
const phoneOpacity = t.interpolate({
|
|
165
|
+
inputRange: [0, 0.12, 0.82, 1],
|
|
166
|
+
outputRange: [0, 1, 1, 0],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const ring = size * 0.78;
|
|
170
|
+
const ringInset = (size - ring) / 2;
|
|
171
|
+
const phoneW = toLandscape ? size * 0.3 : size * 0.56;
|
|
172
|
+
const phoneH = toLandscape ? size * 0.56 : size * 0.3;
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<View
|
|
176
|
+
style={[{ width: size, height: size }, styles.center, style]}
|
|
177
|
+
pointerEvents="none"
|
|
178
|
+
>
|
|
179
|
+
{/* Faint full guide ring — the rotation "path" (centred behind the
|
|
180
|
+
phone via explicit insets; absolute views don't honour the
|
|
181
|
+
parent's center alignment). */}
|
|
182
|
+
<View
|
|
183
|
+
style={[
|
|
184
|
+
styles.ring,
|
|
185
|
+
{
|
|
186
|
+
width: ring,
|
|
187
|
+
height: ring,
|
|
188
|
+
borderRadius: ring / 2,
|
|
189
|
+
top: ringInset,
|
|
190
|
+
left: ringInset,
|
|
191
|
+
borderColor: GUIDANCE_TOKENS.amber,
|
|
192
|
+
},
|
|
193
|
+
]}
|
|
194
|
+
/>
|
|
195
|
+
{/* Arrowhead on the ring at top-centre, pointing along the rotation's
|
|
196
|
+
tangent: LEFT for anticlockwise (to-landscape), RIGHT for clockwise
|
|
197
|
+
(to-portrait). */}
|
|
198
|
+
<View
|
|
199
|
+
style={[
|
|
200
|
+
styles.arrowHead,
|
|
201
|
+
toLandscape ? styles.arrowHeadLeft : styles.arrowHeadRight,
|
|
202
|
+
{ top: ringInset - 5, left: size / 2 - 5 },
|
|
203
|
+
]}
|
|
204
|
+
/>
|
|
205
|
+
|
|
206
|
+
<Animated.View
|
|
207
|
+
style={{ opacity: phoneOpacity, transform: [{ rotate }] }}
|
|
208
|
+
>
|
|
209
|
+
<PhoneBody width={phoneW} height={phoneH} />
|
|
210
|
+
</Animated.View>
|
|
211
|
+
</View>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* PanPhoneGraphic — a phone outline (landscape for Mode-A `down`, portrait
|
|
218
|
+
* for Mode-B `right`) with an amber sweep band that travels across the pan
|
|
219
|
+
* axis on a loop, demonstrating the camera sweep. The band fades in/out at
|
|
220
|
+
* the travel ends so the loop reset is invisible. Replaces
|
|
221
|
+
* `pan-capture.gif`. The bouncing direction arrow stays in PanHowToOverlay.
|
|
222
|
+
*/
|
|
223
|
+
export function PanPhoneGraphic({
|
|
224
|
+
direction,
|
|
225
|
+
size = DEFAULT_SIZE,
|
|
226
|
+
playing = true,
|
|
227
|
+
style,
|
|
228
|
+
}: GuidanceGraphicProps & { direction: PanGraphicDirection }): React.JSX.Element {
|
|
229
|
+
// One value loops 0→1; drives the phone's travel + perspective tilt
|
|
230
|
+
// together so the device reads as ROTATING as it sweeps along the arrow.
|
|
231
|
+
const t = useRef(new Animated.Value(0)).current;
|
|
232
|
+
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
if (!playing) {
|
|
235
|
+
t.setValue(0);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const loop = Animated.loop(
|
|
239
|
+
Animated.timing(t, {
|
|
240
|
+
toValue: 1,
|
|
241
|
+
duration: 1900,
|
|
242
|
+
easing: Easing.inOut(Easing.ease),
|
|
243
|
+
useNativeDriver: true,
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
loop.start();
|
|
247
|
+
return () => loop.stop();
|
|
248
|
+
}, [playing, t, direction]);
|
|
249
|
+
|
|
250
|
+
const down = direction === 'down';
|
|
251
|
+
// Mode A (down) holds the phone LANDSCAPE; Mode B (right) PORTRAIT.
|
|
252
|
+
const phoneW = down ? size * 0.5 : size * 0.34;
|
|
253
|
+
const phoneH = down ? size * 0.34 : size * 0.5;
|
|
254
|
+
|
|
255
|
+
// Travel ± along the pan axis (down → +Y, right → +X), kept in-canvas.
|
|
256
|
+
const amp = size * 0.2;
|
|
257
|
+
const translate = t.interpolate({
|
|
258
|
+
inputRange: [0, 1],
|
|
259
|
+
outputRange: [-amp, amp],
|
|
260
|
+
});
|
|
261
|
+
// The device TILTS through the sweep — rotating about the cross-pan axis
|
|
262
|
+
// as it pans — which is the 3D "the phone is turning" read the flat
|
|
263
|
+
// band lacked. rotateX for a vertical (down) pan, rotateY for horizontal.
|
|
264
|
+
// The horizontal (right) tilt is INVERTED vs the vertical one so the edge
|
|
265
|
+
// on the side the phone is currently on reads LONGER (convex toward the
|
|
266
|
+
// viewer) — matched to on-device feedback for the portrait Mode-B pan.
|
|
267
|
+
const tilt = t.interpolate({
|
|
268
|
+
inputRange: [0, 1],
|
|
269
|
+
outputRange: down ? ['-24deg', '24deg'] : ['24deg', '-24deg'],
|
|
270
|
+
});
|
|
271
|
+
// Fade at the travel ends so the loop's restart is invisible.
|
|
272
|
+
const opacity = t.interpolate({
|
|
273
|
+
inputRange: [0, 0.15, 0.85, 1],
|
|
274
|
+
outputRange: [0, 1, 1, 0],
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// `perspective` makes the rotateX/rotateY read as depth (a turning
|
|
278
|
+
// device), not a flat vertical squash.
|
|
279
|
+
const transform = down
|
|
280
|
+
? [{ perspective: 800 }, { translateY: translate }, { rotateX: tilt }]
|
|
281
|
+
: [{ perspective: 800 }, { translateX: translate }, { rotateY: tilt }];
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<View
|
|
285
|
+
style={[{ width: size, height: size }, styles.center, style]}
|
|
286
|
+
pointerEvents="none"
|
|
287
|
+
>
|
|
288
|
+
<Animated.View style={{ opacity, transform }}>
|
|
289
|
+
<PhoneBody width={phoneW} height={phoneH}>
|
|
290
|
+
{/* Faint amber "screen" so the turning glass catches the light. */}
|
|
291
|
+
<View style={styles.screenGlow} pointerEvents="none" />
|
|
292
|
+
</PhoneBody>
|
|
293
|
+
</Animated.View>
|
|
294
|
+
</View>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
const styles = StyleSheet.create({
|
|
300
|
+
center: { alignItems: 'center', justifyContent: 'center' },
|
|
301
|
+
phoneBody: {
|
|
302
|
+
alignItems: 'center',
|
|
303
|
+
justifyContent: 'center',
|
|
304
|
+
backgroundColor: 'transparent',
|
|
305
|
+
},
|
|
306
|
+
cameraDot: {
|
|
307
|
+
position: 'absolute',
|
|
308
|
+
backgroundColor: GUIDANCE_TOKENS.white,
|
|
309
|
+
},
|
|
310
|
+
ring: {
|
|
311
|
+
position: 'absolute',
|
|
312
|
+
borderWidth: 1.5,
|
|
313
|
+
opacity: 0.28,
|
|
314
|
+
backgroundColor: 'transparent',
|
|
315
|
+
},
|
|
316
|
+
// Amber CSS-triangle arrowhead at the top of the ring. The base props are
|
|
317
|
+
// shared; the direction-specific style colours the trailing border so the
|
|
318
|
+
// apex points along the rotation tangent.
|
|
319
|
+
arrowHead: {
|
|
320
|
+
position: 'absolute',
|
|
321
|
+
width: 0,
|
|
322
|
+
height: 0,
|
|
323
|
+
borderTopWidth: 6,
|
|
324
|
+
borderBottomWidth: 6,
|
|
325
|
+
borderTopColor: 'transparent',
|
|
326
|
+
borderBottomColor: 'transparent',
|
|
327
|
+
},
|
|
328
|
+
// Points LEFT (anticlockwise / to-landscape): RIGHT border amber.
|
|
329
|
+
arrowHeadLeft: {
|
|
330
|
+
borderRightWidth: 10,
|
|
331
|
+
borderRightColor: GUIDANCE_TOKENS.amber,
|
|
332
|
+
},
|
|
333
|
+
// Points RIGHT (clockwise / to-portrait): LEFT border amber.
|
|
334
|
+
arrowHeadRight: {
|
|
335
|
+
borderLeftWidth: 10,
|
|
336
|
+
borderLeftColor: GUIDANCE_TOKENS.amber,
|
|
337
|
+
},
|
|
338
|
+
// Faint amber fill inside the phone outline — a hint of the live
|
|
339
|
+
// preview so the turning device reads as a screen, not an empty frame.
|
|
340
|
+
screenGlow: {
|
|
341
|
+
width: '78%',
|
|
342
|
+
height: '70%',
|
|
343
|
+
borderRadius: 6,
|
|
344
|
+
backgroundColor: GUIDANCE_TOKENS.amber,
|
|
345
|
+
opacity: 0.14,
|
|
346
|
+
},
|
|
347
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* guidanceTokens — the single source of truth for the panorama capture
|
|
4
|
+
* GUIDANCE visual language (rotate prompt, pan how-to, countdown, too-fast
|
|
5
|
+
* pill, lateral popup). Values are taken verbatim from the design handoff
|
|
6
|
+
* ("Camera Capture Guides") so every guidance surface shares exact styling
|
|
7
|
+
* instead of re-declaring colors per component.
|
|
8
|
+
*
|
|
9
|
+
* The two looping device-motion graphics are drawn programmatically (see
|
|
10
|
+
* ./guidanceGraphics — pure RN View + Animated, no image assets); these
|
|
11
|
+
* tokens cover both those graphics and the code-built chrome around them.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const GUIDANCE_TOKENS = {
|
|
15
|
+
/** Device outline, caption text, countdown number. */
|
|
16
|
+
white: '#FFFFFF',
|
|
17
|
+
/** Rotation ring/arrow, pan guide line, dots, glow — the one accent. */
|
|
18
|
+
amber: '#FFC462',
|
|
19
|
+
/** Caption-pill / popup background scrim. */
|
|
20
|
+
scrim: 'rgba(0,0,0,0.42)',
|
|
21
|
+
/** Pill hairline border. */
|
|
22
|
+
hairline: 'rgba(255,255,255,0.16)',
|
|
23
|
+
/** On-screen size (px square) of the rotate / pan guidance graphics. */
|
|
24
|
+
graphicSize: 240,
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Caption-pill spec (item 2 "Rotate to landscape" + reused by the too-fast
|
|
29
|
+
* pill): full pill, scrim bg, hairline border, amber leading dot, white
|
|
30
|
+
* 13px/600 text.
|
|
31
|
+
*/
|
|
32
|
+
export const GUIDANCE_PILL = {
|
|
33
|
+
paddingVertical: 8,
|
|
34
|
+
paddingHorizontal: 15,
|
|
35
|
+
borderRadius: 999,
|
|
36
|
+
dotSize: 6,
|
|
37
|
+
dotGap: 7,
|
|
38
|
+
fontSize: 13,
|
|
39
|
+
fontWeight: '600' as const,
|
|
40
|
+
} as const;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Countdown spec (item 5): amber dot + glow, white 30px/700 tabular-nums
|
|
44
|
+
* number, whole timer blinks opacity 0.18↔1 over a 1s ease-in-out cycle.
|
|
45
|
+
*/
|
|
46
|
+
export const GUIDANCE_COUNTDOWN = {
|
|
47
|
+
dotSize: 9,
|
|
48
|
+
dotGap: 8,
|
|
49
|
+
dotGlow: 'rgba(255,196,98,0.85)',
|
|
50
|
+
fontSize: 30,
|
|
51
|
+
fontWeight: '700' as const,
|
|
52
|
+
blinkMinOpacity: 0.18,
|
|
53
|
+
blinkMaxOpacity: 1,
|
|
54
|
+
blinkPeriodMs: 1000,
|
|
55
|
+
/** top/left inset from the (user-perceived) corner. */
|
|
56
|
+
inset: 16,
|
|
57
|
+
} as const;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* panModeGate — pure decision helper for the first-time-user "rotate the
|
|
4
|
+
* device" gate (guidance item 1).
|
|
5
|
+
*
|
|
6
|
+
* The non-AR panorama flow has two pan directions:
|
|
7
|
+
*
|
|
8
|
+
* - **vertical** — the user holds the phone LANDSCAPE and pans the camera
|
|
9
|
+
* TOP → BOTTOM down a tall fixture. Both `landscape-left` and
|
|
10
|
+
* `landscape-right` are valid holds.
|
|
11
|
+
* - **horizontal** — the user holds the phone PORTRAIT and pans LEFT →
|
|
12
|
+
* RIGHT across a wide scene. Both portrait holds are valid.
|
|
13
|
+
*
|
|
14
|
+
* A host restricts capture via the `panMode` flag:
|
|
15
|
+
* - `'vertical'` → landscape-only; a PORTRAIT hold is gated (rotate to
|
|
16
|
+
* landscape).
|
|
17
|
+
* - `'horizontal'` → portrait-only; a LANDSCAPE hold is gated (rotate to
|
|
18
|
+
* portrait).
|
|
19
|
+
* - `'both'` → either; the gate never fires.
|
|
20
|
+
*
|
|
21
|
+
* When the gate fires the host must NOT start the capture — it shows the
|
|
22
|
+
* rotate prompt (guidance item 2, pointing at the target orientation) and
|
|
23
|
+
* waits for the user to rotate.
|
|
24
|
+
*
|
|
25
|
+
* This module is the single pure predicate for that decision: no React, no
|
|
26
|
+
* sensors, no side effects, so the gate logic is unit-testable in the node
|
|
27
|
+
* jest env without booting a render or mocking the accelerometer.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { DeviceOrientation } from './useDeviceOrientation';
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Which device holds the panorama capture accepts.
|
|
35
|
+
*
|
|
36
|
+
* - `'vertical'` — LANDSCAPE only (top→bottom pan; the product default).
|
|
37
|
+
* Portrait holds are gated behind the rotate-to-landscape prompt.
|
|
38
|
+
* - `'horizontal'` — PORTRAIT only (left→right pan). Landscape holds are
|
|
39
|
+
* gated behind the rotate-to-portrait prompt.
|
|
40
|
+
* - `'both'` — LANDSCAPE or PORTRAIT; the gate never fires, the user
|
|
41
|
+
* captures in whichever hold they're already in.
|
|
42
|
+
*/
|
|
43
|
+
export type PanMode = 'vertical' | 'horizontal' | 'both';
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
function isPortrait(orientation: DeviceOrientation): boolean {
|
|
47
|
+
return (
|
|
48
|
+
orientation === 'portrait' || orientation === 'portrait-upside-down'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* True when the caller must BLOCK capture-start and show the rotate prompt
|
|
55
|
+
* for the current device hold:
|
|
56
|
+
* - `'vertical'` gates a PORTRAIT hold (needs landscape).
|
|
57
|
+
* - `'horizontal'` gates a LANDSCAPE hold (needs portrait).
|
|
58
|
+
* - `'both'` never gates.
|
|
59
|
+
*/
|
|
60
|
+
export function shouldGateForPanMode(
|
|
61
|
+
panMode: PanMode,
|
|
62
|
+
orientation: DeviceOrientation,
|
|
63
|
+
): boolean {
|
|
64
|
+
if (panMode === 'vertical') return isPortrait(orientation);
|
|
65
|
+
if (panMode === 'horizontal') return !isPortrait(orientation);
|
|
66
|
+
return false; // 'both'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The orientation the user must rotate TO when a hold is gated, used to pick
|
|
72
|
+
* the rotate prompt's copy + graphic. `'vertical'` wants landscape,
|
|
73
|
+
* `'horizontal'` wants portrait; `'both'` never gates so returns `null`.
|
|
74
|
+
*/
|
|
75
|
+
export function gateTargetOrientation(
|
|
76
|
+
panMode: PanMode,
|
|
77
|
+
): 'landscape' | 'portrait' | null {
|
|
78
|
+
if (panMode === 'vertical') return 'landscape';
|
|
79
|
+
if (panMode === 'horizontal') return 'portrait';
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* pickCaptureFormat — choose the vision-camera format for the capture stream.
|
|
4
|
+
*
|
|
5
|
+
* Replaces a plain `useCameraFormat([{ videoResolution: 'max' }, …])`, which
|
|
6
|
+
* picks the device's MAX-video format and lets the PHOTO resolution ride
|
|
7
|
+
* along — on the iPhone 16 Pro ultra-wide that pairs a **48 MP** still
|
|
8
|
+
* (8064×6048) with the 4032×3024 max-video format, so a tap photo came out
|
|
9
|
+
* ~6000 px. vision-camera 4.x exposes each format's photo/video resolution
|
|
10
|
+
* but NOT its pixel format / bit-depth, so we can't filter for 8-bit; the
|
|
11
|
+
* empirical rule is that the device's MAX 4:3 video format is 8-bit (the
|
|
12
|
+
* frame processor needs 8-bit for non-AR stitching), and lower video
|
|
13
|
+
* resolutions risk 10-bit.
|
|
14
|
+
*
|
|
15
|
+
* Strategy: among the ~4:3 formats whose photo long-edge is within
|
|
16
|
+
* `maxPhotoLongEdge`, pick the one with the HIGHEST video resolution (keeps
|
|
17
|
+
* the preview/stitch stream as sharp as possible while bounding the still),
|
|
18
|
+
* tie-breaking on higher fps, then the largest photo under the cap, then
|
|
19
|
+
* non-HDR (a hedge toward 8-bit). If NO format fits the cap, fall back to
|
|
20
|
+
* the overall max-video format (never returns nothing for a non-empty list).
|
|
21
|
+
*
|
|
22
|
+
* Verified against the real iPhone 16 Pro ultra-wide format list (see the
|
|
23
|
+
* unit test): cap 4032 → 4032×3024 photo (12 MP) + 3264×2448 video (was
|
|
24
|
+
* 8064×6048 photo); cap 2048 → 2016×1512 photo (3 MP) + 1920×1440 video.
|
|
25
|
+
*
|
|
26
|
+
* Pure + structurally-typed (no vision-camera import) so it unit-tests in the
|
|
27
|
+
* node jest env; `CameraDeviceFormat` is structurally assignable to
|
|
28
|
+
* `FormatLike`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** The CameraDeviceFormat fields this picker reads. */
|
|
32
|
+
export interface FormatLike {
|
|
33
|
+
photoWidth: number;
|
|
34
|
+
photoHeight: number;
|
|
35
|
+
videoWidth: number;
|
|
36
|
+
videoHeight: number;
|
|
37
|
+
maxFps: number;
|
|
38
|
+
supportsVideoHdr: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PickFormatOptions {
|
|
42
|
+
/**
|
|
43
|
+
* Cap on the chosen format's photo LONG edge, in px. The picker prefers
|
|
44
|
+
* the sharpest-video format whose photo fits this. `0` disables the cap
|
|
45
|
+
* (reverts to pure max-video). Default 4032 (≈12 MP at 4:3, "4K"-ish).
|
|
46
|
+
*/
|
|
47
|
+
maxPhotoLongEdge?: number;
|
|
48
|
+
/** Target capture aspect (W/H in landscape). Default 4/3. */
|
|
49
|
+
aspect?: number;
|
|
50
|
+
/** Aspect match tolerance. Default 0.05. */
|
|
51
|
+
aspectTolerance?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Prefer a SMOOTH (high-fps) preview over the sharpest video format. Off by
|
|
54
|
+
* default → max-video-resolution-first (back-compat). On (the panorama
|
|
55
|
+
* camera opts in) → rank by frame rate up to `fpsTarget` first, THEN video
|
|
56
|
+
* resolution. The default video-first sort picks e.g. a 3264×2448 **@30 fps**
|
|
57
|
+
* format over a 1920×1440 **@60 fps** one, halving the preview frame rate —
|
|
58
|
+
* visible as jitter while panning. The stitch clamps keyframes to 640/1280 px
|
|
59
|
+
* anyway, so the higher video resolution buys nothing for the panorama; a
|
|
60
|
+
* 60 fps stream just looks smooth.
|
|
61
|
+
*/
|
|
62
|
+
preferHighFps?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Ceiling for the fps preference when `preferHighFps` is on. Formats at or
|
|
65
|
+
* above this are treated as equally smooth (so resolution breaks the tie
|
|
66
|
+
* instead of chasing 120 fps at a lower resolution). Default 60.
|
|
67
|
+
*/
|
|
68
|
+
fpsTarget?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DEFAULT_MAX_PHOTO_LONG_EDGE = 4032;
|
|
72
|
+
const DEFAULT_FPS_TARGET = 60;
|
|
73
|
+
|
|
74
|
+
const longEdge = (f: FormatLike): number =>
|
|
75
|
+
Math.max(f.photoWidth, f.photoHeight);
|
|
76
|
+
const videoPixels = (f: FormatLike): number => f.videoWidth * f.videoHeight;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pick the best capture format, or `undefined` for an empty list.
|
|
80
|
+
*/
|
|
81
|
+
export function pickCaptureFormat<F extends FormatLike>(
|
|
82
|
+
formats: readonly F[],
|
|
83
|
+
opts: PickFormatOptions = {},
|
|
84
|
+
): F | undefined {
|
|
85
|
+
if (!formats || formats.length === 0) return undefined;
|
|
86
|
+
|
|
87
|
+
const aspect = opts.aspect ?? 4 / 3;
|
|
88
|
+
const tol = opts.aspectTolerance ?? 0.05;
|
|
89
|
+
const cap = opts.maxPhotoLongEdge ?? DEFAULT_MAX_PHOTO_LONG_EDGE;
|
|
90
|
+
const preferHighFps = opts.preferHighFps ?? false;
|
|
91
|
+
const fpsTarget = opts.fpsTarget ?? DEFAULT_FPS_TARGET;
|
|
92
|
+
// Treat everything at/above the target as equally smooth so resolution, not
|
|
93
|
+
// a chase for 120 fps, breaks the tie.
|
|
94
|
+
const smoothness = (f: FormatLike): number => Math.min(f.maxFps, fpsTarget);
|
|
95
|
+
|
|
96
|
+
const matchesAspect = (f: FormatLike): boolean =>
|
|
97
|
+
f.photoHeight > 0
|
|
98
|
+
&& f.videoHeight > 0
|
|
99
|
+
&& Math.abs(f.photoWidth / f.photoHeight - aspect) < tol
|
|
100
|
+
&& Math.abs(f.videoWidth / f.videoHeight - aspect) < tol;
|
|
101
|
+
|
|
102
|
+
// Prefer 4:3 formats; if the device has none, consider all.
|
|
103
|
+
const fourThree = formats.filter(matchesAspect);
|
|
104
|
+
const base = fourThree.length > 0 ? fourThree : formats.slice();
|
|
105
|
+
|
|
106
|
+
// Among those within the photo cap; if none fit, fall back to all (which
|
|
107
|
+
// then resolves to the max-video format — never worse than today).
|
|
108
|
+
const withinCap =
|
|
109
|
+
cap > 0 ? base.filter((f) => longEdge(f) <= cap) : base.slice();
|
|
110
|
+
const candidates = withinCap.length > 0 ? withinCap : base;
|
|
111
|
+
|
|
112
|
+
return candidates.slice().sort((a, b) => {
|
|
113
|
+
if (preferHighFps) {
|
|
114
|
+
// Smooth-preview priority: frame rate (up to the target) before video
|
|
115
|
+
// resolution. Keeps the panorama preview at ~60 fps instead of dropping
|
|
116
|
+
// to a sharper-but-30fps format.
|
|
117
|
+
const sa = smoothness(a);
|
|
118
|
+
const sb = smoothness(b);
|
|
119
|
+
if (sb !== sa) return sb - sa;
|
|
120
|
+
}
|
|
121
|
+
const va = videoPixels(a);
|
|
122
|
+
const vb = videoPixels(b);
|
|
123
|
+
if (vb !== va) return vb - va; // highest video resolution first
|
|
124
|
+
if (b.maxFps !== a.maxFps) return b.maxFps - a.maxFps; // then higher fps
|
|
125
|
+
if (longEdge(b) !== longEdge(a)) return longEdge(b) - longEdge(a); // largest photo under cap
|
|
126
|
+
// Prefer non-HDR — a hedge toward an 8-bit pixel format (the stitch
|
|
127
|
+
// frame processor needs 8-bit; vision-camera doesn't expose bit-depth).
|
|
128
|
+
return (a.supportsVideoHdr ? 1 : 0) - (b.supportsVideoHdr ? 1 : 0);
|
|
129
|
+
})[0];
|
|
130
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* buildStitchDebugInfo — format the stitcher's runtime stats as a compact,
|
|
4
|
+
* multi-line string for the __DEV__-only overlay on the output preview.
|
|
5
|
+
*
|
|
6
|
+
* The operator uses this to SEE how a panorama was built — which pipeline +
|
|
7
|
+
* warper ran, whether the low-memory stream/feather fallback kicked in, the
|
|
8
|
+
* confidence score the successful attempt used, and how many keyframes
|
|
9
|
+
* survived pruning. Purely presentational; never shown in release.
|
|
10
|
+
*
|
|
11
|
+
* Pure + structurally typed so it unit-tests in the node jest env.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface StitchDebugFields {
|
|
15
|
+
/** Native `debugSummary`: `"pipe=…;warp=…;route=…;seam=…;blend=…"`. */
|
|
16
|
+
debugSummary?: string;
|
|
17
|
+
stitchModeResolved?: 'panorama' | 'scans';
|
|
18
|
+
finalConfidenceThresh?: number;
|
|
19
|
+
framesIncluded?: number;
|
|
20
|
+
framesRequested?: number;
|
|
21
|
+
width?: number;
|
|
22
|
+
height?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the overlay text. Returns `''` when nothing useful is present (so the
|
|
27
|
+
* caller can skip rendering the pill entirely). One `key: value` per line.
|
|
28
|
+
*/
|
|
29
|
+
export function buildStitchDebugInfo(r: StitchDebugFields): string {
|
|
30
|
+
const lines: string[] = [];
|
|
31
|
+
|
|
32
|
+
// Expand the native summary ("pipe=manual;warp=spherical;…") into one
|
|
33
|
+
// labelled line per pair, preserving order. Malformed pairs are skipped.
|
|
34
|
+
if (r.debugSummary) {
|
|
35
|
+
for (const pair of r.debugSummary.split(';')) {
|
|
36
|
+
const eq = pair.indexOf('=');
|
|
37
|
+
if (eq <= 0) continue;
|
|
38
|
+
const key = pair.slice(0, eq).trim();
|
|
39
|
+
const value = pair.slice(eq + 1).trim();
|
|
40
|
+
if (key && value) lines.push(`${key}: ${value}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (r.stitchModeResolved) lines.push(`mode: ${r.stitchModeResolved}`);
|
|
45
|
+
|
|
46
|
+
if (
|
|
47
|
+
typeof r.finalConfidenceThresh === 'number'
|
|
48
|
+
&& r.finalConfidenceThresh >= 0
|
|
49
|
+
) {
|
|
50
|
+
lines.push(`score: ${r.finalConfidenceThresh.toFixed(2)}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (typeof r.framesIncluded === 'number' && r.framesIncluded >= 0) {
|
|
54
|
+
const req =
|
|
55
|
+
typeof r.framesRequested === 'number' && r.framesRequested >= 0
|
|
56
|
+
? String(r.framesRequested)
|
|
57
|
+
: '?';
|
|
58
|
+
lines.push(`frames: ${r.framesIncluded}/${req}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (
|
|
62
|
+
typeof r.width === 'number'
|
|
63
|
+
&& typeof r.height === 'number'
|
|
64
|
+
&& r.width > 0
|
|
65
|
+
&& r.height > 0
|
|
66
|
+
) {
|
|
67
|
+
lines.push(`size: ${r.width}×${r.height}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return lines.join('\n');
|
|
71
|
+
}
|