runcloud 0.1.2 → 0.1.3

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.
@@ -0,0 +1,516 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ ActivityIndicator,
4
+ Platform,
5
+ Pressable,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ } from 'react-native';
10
+ import { joinRoom, type Room } from 'trystero';
11
+
12
+ type DemoRole = 'broadcaster' | 'receiver';
13
+ type MediaState = 'idle' | 'requesting' | 'ready' | 'waiting' | 'live' | 'error';
14
+
15
+ const APP_ID = 'run-cloud-live-camera-relay-v1';
16
+ const ROOM_PATTERN = /^[a-z0-9-]{8,80}$/i;
17
+
18
+ function demoParameters(): { role: DemoRole; roomId: string; receiver: number } {
19
+ if (typeof window === 'undefined') {
20
+ return { role: 'receiver', roomId: 'run-cloud-preview', receiver: 1 };
21
+ }
22
+
23
+ const params = new URLSearchParams(window.location.search);
24
+ const room = params.get('room') ?? '';
25
+ const receiver = Number(params.get('receiver'));
26
+ return {
27
+ role: params.get('role') === 'broadcaster' ? 'broadcaster' : 'receiver',
28
+ roomId: ROOM_PATTERN.test(room) ? room : 'run-cloud-preview',
29
+ receiver: Number.isInteger(receiver) && receiver > 0 ? receiver : 1,
30
+ };
31
+ }
32
+
33
+ function VideoSurface({
34
+ stream,
35
+ muted,
36
+ label,
37
+ }: {
38
+ stream: MediaStream;
39
+ muted: boolean;
40
+ label: string;
41
+ }) {
42
+ const ref = useRef<HTMLVideoElement | null>(null);
43
+
44
+ useEffect(() => {
45
+ const video = ref.current;
46
+ if (!video) return;
47
+ video.srcObject = stream;
48
+ void video.play().catch(() => undefined);
49
+ return () => {
50
+ video.srcObject = null;
51
+ };
52
+ }, [stream]);
53
+
54
+ return <video
55
+ ref={ref}
56
+ autoPlay
57
+ muted={muted}
58
+ playsInline
59
+ aria-label={label}
60
+ style={styles.video as React.CSSProperties}
61
+ />;
62
+ }
63
+
64
+ function Waveform({
65
+ stream,
66
+ audioContextRef,
67
+ }: {
68
+ stream: MediaStream | null;
69
+ audioContextRef: React.MutableRefObject<AudioContext | null>;
70
+ }) {
71
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
72
+ const hasAudio = Boolean(stream?.getAudioTracks().length);
73
+
74
+ useEffect(() => {
75
+ const canvas = canvasRef.current;
76
+ if (!canvas) return;
77
+
78
+ const context = canvas.getContext('2d');
79
+ if (!context) return;
80
+
81
+ const AudioContextClass = window.AudioContext
82
+ ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
83
+ const audioContext = stream && hasAudio && AudioContextClass ? new AudioContextClass() : null;
84
+ audioContextRef.current = audioContext;
85
+ const analyser = audioContext ? audioContext.createAnalyser() : null;
86
+ const source = audioContext && analyser && stream
87
+ ? audioContext.createMediaStreamSource(stream)
88
+ : null;
89
+ if (analyser) {
90
+ analyser.fftSize = 256;
91
+ analyser.smoothingTimeConstant = 0.78;
92
+ source?.connect(analyser);
93
+ }
94
+ const samples = analyser ? new Uint8Array(analyser.frequencyBinCount) : null;
95
+ let frame = 0;
96
+
97
+ const draw = () => {
98
+ const width = Math.max(1, canvas.clientWidth);
99
+ const height = Math.max(1, canvas.clientHeight);
100
+ const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
101
+ const pixelWidth = Math.round(width * pixelRatio);
102
+ const pixelHeight = Math.round(height * pixelRatio);
103
+ if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
104
+ canvas.width = pixelWidth;
105
+ canvas.height = pixelHeight;
106
+ }
107
+
108
+ context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
109
+ context.clearRect(0, 0, width, height);
110
+ context.fillStyle = '#070b0c';
111
+ context.fillRect(0, 0, width, height);
112
+ context.strokeStyle = hasAudio ? '#67e8f9' : '#475569';
113
+ context.lineWidth = 2;
114
+ context.beginPath();
115
+
116
+ if (analyser && samples) analyser.getByteTimeDomainData(samples);
117
+ const count = samples?.length ?? 64;
118
+ for (let index = 0; index < count; index += 1) {
119
+ const x = count === 1 ? 0 : (index / (count - 1)) * width;
120
+ const sample = samples ? (samples[index] - 128) / 128 : 0;
121
+ const y = height / 2 + sample * height * 0.38;
122
+ if (index === 0) context.moveTo(x, y);
123
+ else context.lineTo(x, y);
124
+ }
125
+ context.stroke();
126
+ frame = window.requestAnimationFrame(draw);
127
+ };
128
+
129
+ frame = window.requestAnimationFrame(draw);
130
+ return () => {
131
+ window.cancelAnimationFrame(frame);
132
+ source?.disconnect();
133
+ void audioContext?.close();
134
+ if (audioContextRef.current === audioContext) audioContextRef.current = null;
135
+ };
136
+ }, [audioContextRef, hasAudio, stream]);
137
+
138
+ return (
139
+ <View style={styles.waveformSection}>
140
+ <View style={styles.waveformHeading}>
141
+ <Text style={styles.label}>AUDIO TRACK</Text>
142
+ <Text style={[styles.trackState, hasAudio ? styles.trackLive : styles.trackAbsent]}>
143
+ {hasAudio ? 'RECEIVING' : 'NO TRACK'}
144
+ </Text>
145
+ </View>
146
+ <canvas
147
+ ref={canvasRef}
148
+ aria-label={hasAudio ? 'Live audio waveform' : 'No audio track waveform'}
149
+ style={styles.waveform as React.CSSProperties}
150
+ />
151
+ </View>
152
+ );
153
+ }
154
+
155
+ function stateLabel(state: MediaState): string {
156
+ if (state === 'requesting') return 'OPENING CAMERA';
157
+ if (state === 'ready') return 'CAMERA READY';
158
+ if (state === 'waiting') return 'WAITING FOR SOURCE';
159
+ if (state === 'live') return 'LIVE';
160
+ if (state === 'error') return 'ERROR';
161
+ return 'READY';
162
+ }
163
+
164
+ export default function App() {
165
+ const params = useMemo(demoParameters, []);
166
+ const [mediaState, setMediaState] = useState<MediaState>(
167
+ params.role === 'broadcaster' ? 'idle' : 'waiting',
168
+ );
169
+ const [error, setError] = useState<string | null>(null);
170
+ const [peerIds, setPeerIds] = useState<string[]>([]);
171
+ const [localStream, setLocalStream] = useState<MediaStream | null>(null);
172
+ const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
173
+ const [receiverMuted, setReceiverMuted] = useState(true);
174
+ const roomRef = useRef<Room | null>(null);
175
+ const localStreamRef = useRef<MediaStream | null>(null);
176
+ const remoteStreamRef = useRef<MediaStream | null>(null);
177
+ const audioContextRef = useRef<AudioContext | null>(null);
178
+
179
+ useEffect(() => {
180
+ if (Platform.OS !== 'web') return;
181
+
182
+ const room = joinRoom(
183
+ { appId: APP_ID },
184
+ params.roomId,
185
+ {
186
+ onJoinError: ({ error: message }) => {
187
+ setError(message);
188
+ setMediaState('error');
189
+ },
190
+ },
191
+ );
192
+ roomRef.current = room;
193
+ room.onPeerJoin = (peerId) => {
194
+ setPeerIds((current) => current.includes(peerId) ? current : [...current, peerId]);
195
+ const stream = localStreamRef.current;
196
+ if (params.role === 'broadcaster' && stream) {
197
+ void Promise.allSettled(room.addStream(stream, { target: peerId }));
198
+ }
199
+ };
200
+ room.onPeerLeave = (peerId) => {
201
+ setPeerIds((current) => current.filter((candidate) => candidate !== peerId));
202
+ };
203
+ room.onPeerStream = (stream) => {
204
+ if (params.role !== 'receiver') return;
205
+ remoteStreamRef.current = stream;
206
+ setRemoteStream(stream);
207
+ setMediaState('live');
208
+ for (const track of stream.getTracks()) {
209
+ track.addEventListener('ended', () => {
210
+ if (remoteStreamRef.current !== stream) return;
211
+ if (stream.getTracks().some((candidate) => candidate.readyState !== 'ended')) return;
212
+ remoteStreamRef.current = null;
213
+ setRemoteStream(null);
214
+ setMediaState('waiting');
215
+ }, { once: true });
216
+ }
217
+ };
218
+
219
+ return () => {
220
+ roomRef.current = null;
221
+ remoteStreamRef.current = null;
222
+ void room.leave();
223
+ };
224
+ }, [params.role, params.roomId]);
225
+
226
+ useEffect(() => () => {
227
+ for (const track of localStreamRef.current?.getTracks() ?? []) track.stop();
228
+ }, []);
229
+
230
+ const startBroadcast = useCallback(async () => {
231
+ if (!navigator.mediaDevices?.getUserMedia) {
232
+ setError('Camera access is unavailable in this browser.');
233
+ setMediaState('error');
234
+ return;
235
+ }
236
+
237
+ setError(null);
238
+ setMediaState('requesting');
239
+ let stream: MediaStream;
240
+ try {
241
+ stream = await navigator.mediaDevices.getUserMedia({
242
+ video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } },
243
+ audio: true,
244
+ });
245
+ } catch (withAudioError) {
246
+ try {
247
+ stream = await navigator.mediaDevices.getUserMedia({
248
+ video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } },
249
+ audio: false,
250
+ });
251
+ } catch (videoError) {
252
+ setError(videoError instanceof Error ? videoError.message : String(withAudioError));
253
+ setMediaState('error');
254
+ return;
255
+ }
256
+ }
257
+
258
+ const previousStream = localStreamRef.current;
259
+ if (previousStream) roomRef.current?.removeStream(previousStream);
260
+ for (const track of previousStream?.getTracks() ?? []) track.stop();
261
+ localStreamRef.current = stream;
262
+ setLocalStream(stream);
263
+ setMediaState('ready');
264
+ const sends = roomRef.current?.addStream(stream) ?? [];
265
+ void Promise.allSettled(sends);
266
+ }, []);
267
+
268
+ if (Platform.OS !== 'web') {
269
+ return (
270
+ <View style={styles.unsupported}>
271
+ <Text style={styles.title}>Open this example with Expo Web.</Text>
272
+ </View>
273
+ );
274
+ }
275
+
276
+ const roleName = params.role === 'broadcaster'
277
+ ? 'BROADCASTER'
278
+ : `RECEIVER ${String(params.receiver).padStart(2, '0')}`;
279
+ const stream = params.role === 'broadcaster' ? localStream : remoteStream;
280
+ const isLive = params.role === 'broadcaster'
281
+ ? Boolean(localStream)
282
+ : mediaState === 'live';
283
+
284
+ return (
285
+ <View style={styles.page}>
286
+ <View style={styles.header}>
287
+ <View style={styles.brandRow}>
288
+ <View style={styles.brandMark} />
289
+ <View>
290
+ <Text style={styles.brand}>run.cloud</Text>
291
+ <Text style={styles.role}>{roleName}</Text>
292
+ </View>
293
+ </View>
294
+ <View style={styles.statusRow}>
295
+ <View style={[styles.statusDot, isLive ? styles.statusDotLive : null]} />
296
+ <Text style={[styles.status, isLive ? styles.statusLive : null]}>
297
+ {stateLabel(mediaState)}
298
+ </Text>
299
+ </View>
300
+ </View>
301
+
302
+ <View style={styles.stage}>
303
+ {stream ? (
304
+ <VideoSurface
305
+ stream={stream}
306
+ muted={params.role === 'broadcaster' || receiverMuted}
307
+ label={params.role === 'broadcaster' ? 'Local camera preview' : 'Remote camera stream'}
308
+ />
309
+ ) : (
310
+ <View style={styles.placeholder}>
311
+ {mediaState === 'requesting' ? <ActivityIndicator color="#67e8f9" size="large" /> : null}
312
+ <Text style={styles.placeholderTitle}>
313
+ {params.role === 'broadcaster' ? 'CAMERA STANDBY' : 'SOURCE STANDBY'}
314
+ </Text>
315
+ <Text style={styles.placeholderText}>
316
+ {params.role === 'broadcaster'
317
+ ? 'Start the camera to publish this simulator feed.'
318
+ : 'The video feed appears when the broadcaster starts.'}
319
+ </Text>
320
+ </View>
321
+ )}
322
+
323
+ <View style={styles.stageLabel}>
324
+ <Text style={styles.label}>ROOM</Text>
325
+ <Text numberOfLines={1} style={styles.roomId}>{params.roomId}</Text>
326
+ </View>
327
+ </View>
328
+
329
+ {params.role === 'receiver' ? (
330
+ <Waveform stream={remoteStream} audioContextRef={audioContextRef} />
331
+ ) : null}
332
+
333
+ <View style={styles.footer}>
334
+ <View>
335
+ <Text style={styles.label}>PEERS</Text>
336
+ <Text style={styles.footerValue}>{peerIds.length}</Text>
337
+ </View>
338
+ {params.role === 'broadcaster' ? (
339
+ <Pressable
340
+ accessibilityRole="button"
341
+ disabled={mediaState === 'requesting'}
342
+ onPress={() => void startBroadcast()}
343
+ style={({ pressed }) => [
344
+ styles.primaryButton,
345
+ pressed ? styles.buttonPressed : null,
346
+ mediaState === 'requesting' ? styles.buttonDisabled : null,
347
+ ]}
348
+ >
349
+ <Text style={styles.primaryButtonText}>
350
+ {localStream ? 'Restart camera' : 'Start camera'}
351
+ </Text>
352
+ </Pressable>
353
+ ) : remoteStream?.getAudioTracks().length ? (
354
+ <Pressable
355
+ accessibilityRole="button"
356
+ onPress={() => {
357
+ if (receiverMuted) void audioContextRef.current?.resume();
358
+ setReceiverMuted((current) => !current);
359
+ }}
360
+ style={({ pressed }) => [styles.secondaryButton, pressed ? styles.buttonPressed : null]}
361
+ >
362
+ <Text style={styles.secondaryButtonText}>
363
+ {receiverMuted ? 'Enable audio' : 'Mute audio'}
364
+ </Text>
365
+ </Pressable>
366
+ ) : (
367
+ <View style={styles.transportBadge}>
368
+ <Text style={styles.transportText}>WEBRTC DIRECT</Text>
369
+ </View>
370
+ )}
371
+ </View>
372
+
373
+ {error ? (
374
+ <View style={styles.errorBar}>
375
+ <Text numberOfLines={2} style={styles.errorText}>{error}</Text>
376
+ </View>
377
+ ) : null}
378
+ </View>
379
+ );
380
+ }
381
+
382
+ const styles = StyleSheet.create({
383
+ page: {
384
+ flex: 1,
385
+ minHeight: '100%',
386
+ backgroundColor: '#030505',
387
+ color: '#f8fafc',
388
+ },
389
+ header: {
390
+ height: 68,
391
+ paddingHorizontal: 18,
392
+ borderBottomColor: '#243034',
393
+ borderBottomWidth: 1,
394
+ backgroundColor: '#070a0b',
395
+ flexDirection: 'row',
396
+ alignItems: 'center',
397
+ justifyContent: 'space-between',
398
+ },
399
+ brandRow: { flexDirection: 'row', alignItems: 'center', gap: 11 },
400
+ brandMark: {
401
+ width: 25,
402
+ height: 16,
403
+ borderColor: '#67e8f9',
404
+ borderWidth: 2,
405
+ transform: [{ skewX: '-18deg' }],
406
+ },
407
+ brand: { color: '#f8fafc', fontSize: 15, fontWeight: '700' },
408
+ role: { color: '#94a3b8', fontFamily: 'monospace', fontSize: 10, marginTop: 2 },
409
+ statusRow: { flexDirection: 'row', alignItems: 'center', gap: 7 },
410
+ statusDot: { width: 7, height: 7, borderRadius: 4, backgroundColor: '#64748b' },
411
+ statusDotLive: { backgroundColor: '#bef264' },
412
+ status: { color: '#94a3b8', fontFamily: 'monospace', fontSize: 10 },
413
+ statusLive: { color: '#bef264' },
414
+ stage: { flex: 1, minHeight: 0, position: 'relative', backgroundColor: '#020303' },
415
+ video: { width: '100%', height: '100%', objectFit: 'cover', backgroundColor: '#020303' },
416
+ placeholder: {
417
+ flex: 1,
418
+ alignItems: 'center',
419
+ justifyContent: 'center',
420
+ gap: 14,
421
+ padding: 28,
422
+ },
423
+ placeholderTitle: { color: '#e2e8f0', fontFamily: 'monospace', fontSize: 15 },
424
+ placeholderText: {
425
+ maxWidth: 310,
426
+ color: '#64748b',
427
+ fontSize: 13,
428
+ lineHeight: 20,
429
+ textAlign: 'center',
430
+ },
431
+ stageLabel: {
432
+ position: 'absolute',
433
+ left: 12,
434
+ right: 12,
435
+ bottom: 12,
436
+ minHeight: 43,
437
+ borderColor: '#334155',
438
+ borderWidth: 1,
439
+ backgroundColor: 'rgba(3, 5, 5, 0.84)',
440
+ paddingHorizontal: 11,
441
+ paddingVertical: 7,
442
+ },
443
+ label: { color: '#64748b', fontFamily: 'monospace', fontSize: 9 },
444
+ roomId: { color: '#cbd5e1', fontFamily: 'monospace', fontSize: 11, marginTop: 2 },
445
+ waveformSection: {
446
+ height: 112,
447
+ borderTopColor: '#243034',
448
+ borderTopWidth: 1,
449
+ backgroundColor: '#070b0c',
450
+ paddingHorizontal: 14,
451
+ paddingTop: 10,
452
+ paddingBottom: 12,
453
+ },
454
+ waveformHeading: {
455
+ flexDirection: 'row',
456
+ justifyContent: 'space-between',
457
+ alignItems: 'center',
458
+ marginBottom: 7,
459
+ },
460
+ trackState: { fontFamily: 'monospace', fontSize: 9 },
461
+ trackLive: { color: '#67e8f9' },
462
+ trackAbsent: { color: '#64748b' },
463
+ waveform: { width: '100%', height: 62 },
464
+ footer: {
465
+ minHeight: 72,
466
+ paddingHorizontal: 16,
467
+ paddingVertical: 12,
468
+ borderTopColor: '#243034',
469
+ borderTopWidth: 1,
470
+ backgroundColor: '#070a0b',
471
+ flexDirection: 'row',
472
+ alignItems: 'center',
473
+ justifyContent: 'space-between',
474
+ },
475
+ footerValue: { color: '#f8fafc', fontFamily: 'monospace', fontSize: 17, marginTop: 2 },
476
+ primaryButton: {
477
+ minWidth: 130,
478
+ height: 44,
479
+ paddingHorizontal: 18,
480
+ borderRadius: 4,
481
+ backgroundColor: '#f8fafc',
482
+ alignItems: 'center',
483
+ justifyContent: 'center',
484
+ },
485
+ primaryButtonText: { color: '#020617', fontSize: 13, fontWeight: '700' },
486
+ secondaryButton: {
487
+ height: 40,
488
+ paddingHorizontal: 15,
489
+ borderRadius: 4,
490
+ borderColor: '#475569',
491
+ borderWidth: 1,
492
+ alignItems: 'center',
493
+ justifyContent: 'center',
494
+ },
495
+ secondaryButtonText: { color: '#e2e8f0', fontSize: 12, fontWeight: '600' },
496
+ buttonPressed: { opacity: 0.72 },
497
+ buttonDisabled: { opacity: 0.5 },
498
+ transportBadge: { borderColor: '#334155', borderWidth: 1, paddingHorizontal: 10, paddingVertical: 7 },
499
+ transportText: { color: '#94a3b8', fontFamily: 'monospace', fontSize: 9 },
500
+ errorBar: {
501
+ borderTopColor: '#7f1d1d',
502
+ borderTopWidth: 1,
503
+ backgroundColor: '#2b0b0d',
504
+ paddingHorizontal: 14,
505
+ paddingVertical: 10,
506
+ },
507
+ errorText: { color: '#fecdd3', fontSize: 11, lineHeight: 16 },
508
+ unsupported: {
509
+ flex: 1,
510
+ alignItems: 'center',
511
+ justifyContent: 'center',
512
+ backgroundColor: '#030505',
513
+ padding: 24,
514
+ },
515
+ title: { color: '#f8fafc', fontSize: 20, fontWeight: '700', textAlign: 'center' },
516
+ });
@@ -0,0 +1,56 @@
1
+ # Live camera relay
2
+
3
+ Start three run.cloud iOS simulator sessions: one camera broadcaster and two
4
+ receivers. A local viewer connects your computer webcam to the broadcaster
5
+ simulator's camera. The Expo web app opens that camera, shows the local preview,
6
+ and sends the real WebRTC video stream to both receiver simulators.
7
+
8
+ ## Requirements
9
+
10
+ - Node.js 20 or newer
11
+ - `runcloud` 0.1.3 or newer: `npm install -g runcloud@0.1.3`
12
+ - An existing simulator account authenticated with `runcloud login`
13
+ - run.cloud simulator access, capacity, and enough balance for three sessions
14
+ - A browser with webcam permission
15
+
16
+ The local viewer binds only to `127.0.0.1`. Signed simulator URLs and camera
17
+ credentials remain inside the viewer process and are not printed to the
18
+ terminal. Every simulator is released automatically after two minutes. A
19
+ ten-minute server-side timeout is also set in case the local process exits.
20
+
21
+ ## Run the live demo
22
+
23
+ ```bash
24
+ npm run demo -- --open
25
+ ```
26
+
27
+ In the local viewer:
28
+
29
+ 1. Select **Connect webcam** and allow browser camera access.
30
+ 2. In the broadcaster simulator, select **Start camera** and allow camera access.
31
+ 3. The two receivers display the broadcaster's stream.
32
+
33
+ The simulator camera passthrough carries video frames. The app requests an
34
+ audio track when the simulator provides one; otherwise receivers show a flat
35
+ waveform labeled **NO TRACK** instead of simulating audio.
36
+
37
+ Press Ctrl+C or select **End demo** to release all sessions early. Use
38
+ `--duration 60` to choose a duration between 1 and 600 seconds.
39
+
40
+ ## Work on the Expo app
41
+
42
+ ```bash
43
+ npm install
44
+ npm run web
45
+ ```
46
+
47
+ Open a broadcaster and two receivers with the same room id:
48
+
49
+ ```text
50
+ /?role=broadcaster&room=local-camera-room
51
+ /?role=receiver&receiver=1&room=local-camera-room
52
+ /?role=receiver&receiver=2&room=local-camera-room
53
+ ```
54
+
55
+ Set `RUN_CLOUD_CAMERA_DEMO_URL` to an absolute HTTP or HTTPS app URL when
56
+ testing a local or preview deployment with the simulator controller.
@@ -0,0 +1,17 @@
1
+ {
2
+ "expo": {
3
+ "name": "run.cloud live camera relay",
4
+ "slug": "run-cloud-live-camera-relay",
5
+ "version": "1.0.0",
6
+ "orientation": "portrait",
7
+ "userInterfaceStyle": "dark",
8
+ "web": {
9
+ "output": "single",
10
+ "name": "run.cloud live camera relay",
11
+ "shortName": "Camera relay"
12
+ },
13
+ "experiments": {
14
+ "baseUrl": "/run-cloud-examples/live-camera-relay"
15
+ }
16
+ }
17
+ }