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.
- package/dist/api.js +78 -37
- package/dist/commands/build.js +244 -13
- package/dist/commands/create.js +106 -30
- package/dist/commands/deploy.js +100 -129
- package/dist/commands/desktop.js +46 -0
- package/dist/commands/environment.js +290 -0
- package/dist/commands/login.js +4 -2
- package/dist/commands/run-cloud.js +297 -77
- package/dist/commands/sandbox.js +136 -0
- package/dist/config.js +50 -9
- package/dist/environmentShell.js +70 -0
- package/dist/index.js +4 -0
- package/dist/run-cloud.js +6 -2
- package/dist/services-manifest.js +30 -1
- package/examples/eight-device-mosaic/README.md +30 -0
- package/examples/eight-device-mosaic/demo.mjs +329 -0
- package/examples/eight-device-mosaic/package.json +13 -0
- package/examples/eight-device-mosaic/verify.mjs +115 -0
- package/examples/live-camera-relay/App.tsx +516 -0
- package/examples/live-camera-relay/README.md +57 -0
- package/examples/live-camera-relay/app.json +17 -0
- package/examples/live-camera-relay/demo.mjs +472 -0
- package/examples/live-camera-relay/index.ts +4 -0
- package/examples/live-camera-relay/package.json +31 -0
- package/examples/live-camera-relay/tsconfig.json +6 -0
- package/examples/live-camera-relay/verify.mjs +151 -0
- package/examples/parallel-simulators/README.md +33 -0
- package/examples/parallel-simulators/demo.mjs +185 -0
- package/examples/parallel-simulators/package.json +13 -0
- package/examples/parallel-simulators/verify.mjs +78 -0
- package/package.json +5 -3
|
@@ -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.
|