runcloud 0.1.2 → 0.1.4

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,115 @@
1
+ import assert from 'node:assert/strict';
2
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import test from 'node:test';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import {
7
+ browserCommand,
8
+ mosaicViewerHtml,
9
+ parseOptions,
10
+ runDemo,
11
+ startMosaicViewer,
12
+ } from './demo.mjs';
13
+
14
+ test('uses a fixed lease for the eight-device mosaic', () => {
15
+ assert.deepEqual(parseOptions([]), { open: false });
16
+ });
17
+
18
+ test('accepts auto-open and rejects duration overrides and unsupported counts', () => {
19
+ assert.deepEqual(parseOptions(['--open']), { open: true });
20
+ assert.throws(() => parseOptions(['--duration', '30']), /Unknown option: --duration/);
21
+ assert.throws(() => parseOptions(['--count', '4']), /Unknown option/);
22
+ });
23
+
24
+ test('opens the local viewer on Windows with the required empty title', () => {
25
+ assert.deepEqual(browserCommand('http://127.0.0.1:4321', 'win32'), [
26
+ 'cmd',
27
+ ['/c', 'start', '', 'http://127.0.0.1:4321'],
28
+ ]);
29
+ });
30
+
31
+ test('renders eight signed embeds without printing them into the viewer URL', async () => {
32
+ const sessions = Array.from({ length: 8 }, (_, index) => ({
33
+ id: `mosaic-${index + 1}`,
34
+ url: `https://sim.example/session-${index + 1}?token=secret-${index + 1}`,
35
+ }));
36
+ const html = mosaicViewerHtml(sessions, 601_000);
37
+ assert.equal((html.match(/<iframe /g) || []).length, 8);
38
+ assert.match(html, /loadingGuard=1/);
39
+ assert.match(html, /const deadline = 601000/);
40
+
41
+ const viewer = await startMosaicViewer(sessions, 601_000);
42
+ try {
43
+ assert.doesNotMatch(viewer.url, /secret/);
44
+ const response = await fetch(viewer.url);
45
+ assert.equal(response.status, 200);
46
+ assert.equal(response.headers.get('cache-control'), 'no-store');
47
+ const finish = await fetch(new URL('/finish', viewer.url), { method: 'POST' });
48
+ assert.equal(finish.status, 204);
49
+ assert.equal(await viewer.finished, 'viewer');
50
+ } finally {
51
+ await viewer.close();
52
+ }
53
+ });
54
+
55
+ test('starts, tiles, and releases all eight simulators', async () => {
56
+ const root = mkdtempSync(join(tmpdir(), 'run-cloud-mosaic-demo-'));
57
+ const fakeCli = join(root, 'runcloud');
58
+ const operationLog = join(root, 'operations.jsonl');
59
+ writeFileSync(
60
+ fakeCli,
61
+ `#!/usr/bin/env node
62
+ import { appendFileSync } from 'node:fs';
63
+ const args = process.argv.slice(2);
64
+ appendFileSync(process.env.RUN_CLOUD_DEMO_LOG, JSON.stringify(args) + '\\n');
65
+ if (args[0] === 'ios' && args[1] === 'create') {
66
+ const name = args[args.indexOf('--display-name') + 1];
67
+ process.stdout.write(JSON.stringify({ id: name, url: 'https://sim.example/' + name + '?token=secret' }));
68
+ } else {
69
+ process.stdout.write(JSON.stringify({ ok: true }));
70
+ }
71
+ `,
72
+ );
73
+ chmodSync(fakeCli, 0o755);
74
+
75
+ try {
76
+ const previousBin = process.env.RUNCLOUD_BIN;
77
+ const previousLog = process.env.RUN_CLOUD_DEMO_LOG;
78
+ const previousOrigin = process.env.RUN_CLOUD_DEMO_ORIGIN;
79
+ const output = [];
80
+ const log = console.log;
81
+ process.env.RUNCLOUD_BIN = fakeCli;
82
+ process.env.RUN_CLOUD_DEMO_LOG = operationLog;
83
+ process.env.RUN_CLOUD_DEMO_ORIGIN = 'https://preview.run.cloud';
84
+ console.log = (...values) => output.push(values.join(' '));
85
+ try {
86
+ await runDemo([], { leaseMs: 10 });
87
+ } finally {
88
+ console.log = log;
89
+ if (previousBin === undefined) delete process.env.RUNCLOUD_BIN;
90
+ else process.env.RUNCLOUD_BIN = previousBin;
91
+ if (previousLog === undefined) delete process.env.RUN_CLOUD_DEMO_LOG;
92
+ else process.env.RUN_CLOUD_DEMO_LOG = previousLog;
93
+ if (previousOrigin === undefined) delete process.env.RUN_CLOUD_DEMO_ORIGIN;
94
+ else process.env.RUN_CLOUD_DEMO_ORIGIN = previousOrigin;
95
+ }
96
+ assert.doesNotMatch(output.join('\n'), /token=secret/);
97
+ const operations = readFileSync(operationLog, 'utf8')
98
+ .trim()
99
+ .split('\n')
100
+ .map((line) => JSON.parse(line));
101
+ const creates = operations.filter((args) => args[1] === 'create');
102
+ const opens = operations.filter((args) => args[1] === 'open-url');
103
+ const deletes = operations.filter((args) => args[1] === 'delete');
104
+ assert.equal(creates.length, 8);
105
+ assert.equal(opens.length, 8);
106
+ assert.equal(deletes.length, 8);
107
+ assert.deepEqual(
108
+ opens.map((args) => new URL(args[2]).searchParams.get('tile')).sort(),
109
+ ['0', '1', '2', '3', '4', '5', '6', '7'],
110
+ );
111
+ assert.ok(opens.every((args) => new URL(args[2]).origin === 'https://preview.run.cloud'));
112
+ } finally {
113
+ rmSync(root, { recursive: true, force: true });
114
+ }
115
+ });
@@ -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,57 @@
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`
12
+ - A simulator account authenticated either with `runcloud login` or with both
13
+ `RUN_CLOUD_API_KEY` and `RUN_CLOUD_API_URL`
14
+ - run.cloud simulator access, capacity, and enough balance for three sessions
15
+ - A browser with webcam permission
16
+
17
+ The local viewer binds only to `127.0.0.1`. Signed simulator URLs and camera
18
+ credentials remain inside the viewer process and are not printed to the
19
+ terminal. Every simulator is released automatically after two minutes. A
20
+ ten-minute server-side timeout is also set in case the local process exits.
21
+
22
+ ## Run the live demo
23
+
24
+ ```bash
25
+ npm run demo -- --open
26
+ ```
27
+
28
+ In the local viewer:
29
+
30
+ 1. Select **Connect webcam** and allow browser camera access.
31
+ 2. In the broadcaster simulator, select **Start camera** and allow camera access.
32
+ 3. The two receivers display the broadcaster's stream.
33
+
34
+ The simulator camera passthrough carries video frames. The app requests an
35
+ audio track when the simulator provides one; otherwise receivers show a flat
36
+ waveform labeled **NO TRACK** instead of simulating audio.
37
+
38
+ Press Ctrl+C or select **End demo** to release all sessions early. Use
39
+ `--duration 60` to choose a duration between 1 and 600 seconds.
40
+
41
+ ## Work on the Expo app
42
+
43
+ ```bash
44
+ npm install
45
+ npm run web
46
+ ```
47
+
48
+ Open a broadcaster and two receivers with the same room id:
49
+
50
+ ```text
51
+ /?role=broadcaster&room=local-camera-room
52
+ /?role=receiver&receiver=1&room=local-camera-room
53
+ /?role=receiver&receiver=2&room=local-camera-room
54
+ ```
55
+
56
+ Set `RUN_CLOUD_CAMERA_DEMO_URL` to an absolute HTTP or HTTPS app URL when
57
+ testing a local or preview deployment with the simulator controller.