zebra-report: deploy FSM-driven publish lifecycle (mic/camera/screen)
This commit is contained in:
parent
bbffd61d89
commit
1f19037c6f
1 changed files with 74 additions and 25 deletions
|
|
@ -2381,25 +2381,43 @@ async function sfuPublish(){
|
|||
logLine('err','sfu publish skipped: mic='+(!!micStream)+' keys='+(!!myKeys)+' room='+(!!roomID));
|
||||
return;
|
||||
}
|
||||
/* Drive the existing publishSpec FSM in lock-step with the
|
||||
* imperative flow. The mic publisher transitions
|
||||
* off ──START──▶ acquiring ──ACQUIRED──▶ negotiating ──NEGOTIATED──▶ live
|
||||
* — and back to off via stopping on STOP / FAILED. ctx tracks stream
|
||||
* + pc + peerID. The page's imperative state (sfuPubPC, sfuPubPeerID)
|
||||
* remains the source of truth for now; the FSM is a parallel view
|
||||
* that future observers can hang side effects off of. Fox 2026-06-04
|
||||
* "all systems need state machines." */
|
||||
const fsm = roomMachines.pubs.mic;
|
||||
fsm.send('START');
|
||||
fsm.send('ACQUIRED', { stream: micStream });
|
||||
logLine('', 'sfu publish: starting (base='+SFU_BASE+')');
|
||||
const pc = new RTCPeerConnection(rtcConfig);
|
||||
for (const tr of micStream.getTracks()){ tagTrack(tr); pc.addTrack(tr, micStream); }
|
||||
setSenderBitrate(pc.getSenders().find(s=>s.track && s.track.kind==='audio'));
|
||||
const offer = await pc.createOffer();
|
||||
offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000, { music: musicMode });
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGathering(pc);
|
||||
const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ sdp: pc.localDescription.sdp })
|
||||
});
|
||||
if (res.status === 403){ pc.close(); handleBlocked('publish'); return; }
|
||||
if (!res.ok){ pc.close(); throw new Error('sfu publish http '+res.status); }
|
||||
const ans = await res.json();
|
||||
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
||||
sfuPubPC = pc; sfuPubPeerID = ans.peer_id;
|
||||
watchPublishPC(pc, 'mic', sfuPublish, () => sfuPubPC === pc, () => { sfuPubPC = null; sfuPubPeerID = null; });
|
||||
logLine('', 'sfu: publishing as '+sfuPubPeerID);
|
||||
try {
|
||||
const offer = await pc.createOffer();
|
||||
offer.sdp = preferStereoOpus(offer.sdp, musicMode ? 256000 : 40000, { music: musicMode });
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGathering(pc);
|
||||
const res = await fetch(SFU_BASE + '/publish?room=' + encodeURIComponent(roomID) + '&pub=' + myKeys.pubHex, {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ sdp: pc.localDescription.sdp })
|
||||
});
|
||||
if (res.status === 403){ pc.close(); fsm.send('FAILED', { error: 'blocked' }); fsm.send('DONE'); handleBlocked('publish'); return; }
|
||||
if (!res.ok){ pc.close(); fsm.send('FAILED', { error: 'http '+res.status }); fsm.send('DONE'); throw new Error('sfu publish http '+res.status); }
|
||||
const ans = await res.json();
|
||||
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
||||
sfuPubPC = pc; sfuPubPeerID = ans.peer_id;
|
||||
fsm.send('NEGOTIATED', { pc, peerID: ans.peer_id });
|
||||
watchPublishPC(pc, 'mic', sfuPublish, () => sfuPubPC === pc, () => { sfuPubPC = null; sfuPubPeerID = null; fsm.send('STOP'); fsm.send('DONE'); });
|
||||
logLine('', 'sfu: publishing as '+sfuPubPeerID);
|
||||
} catch(e){
|
||||
fsm.send('FAILED', { error: e.message });
|
||||
fsm.send('DONE');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/* Publish-side ICE failure recovery. The SFU reaps a failed publisher
|
||||
|
|
@ -2430,6 +2448,8 @@ function watchPublishPC(pc, label, rebuildFn, wasOurs, cleanup){
|
|||
/* ----- screen share ----- */
|
||||
async function sfuPublishScreen(){
|
||||
if (sfuScreenPC || !myKeys || !roomID) return;
|
||||
const fsm = roomMachines.pubs.screen;
|
||||
fsm.send('START');
|
||||
let stream;
|
||||
try {
|
||||
/* broadcast-quality capture: 1080p30 video, raw stereo 48kHz audio.
|
||||
|
|
@ -2448,7 +2468,8 @@ async function sfuPublishScreen(){
|
|||
selfBrowserSurface: 'include',
|
||||
systemAudio: 'include',
|
||||
});
|
||||
} catch(e){ logLine('err','screen share cancelled: '+e.message); return; }
|
||||
} catch(e){ logLine('err','screen share cancelled: '+e.message); fsm.send('FAILED', { error: e.message }); fsm.send('DONE'); return; }
|
||||
fsm.send('ACQUIRED', { stream });
|
||||
sfuScreenStream = stream;
|
||||
const vTracks = stream.getVideoTracks(), aTracks = stream.getAudioTracks();
|
||||
logLine('', 'screen capture: '+vTracks.length+' video + '+aTracks.length+' audio track(s)');
|
||||
|
|
@ -2484,12 +2505,13 @@ async function sfuPublishScreen(){
|
|||
let res;
|
||||
try { res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ sdp: pc.localDescription.sdp }) }); }
|
||||
catch(e){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; throw e; }
|
||||
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; handleBlocked('publish-screen'); return; }
|
||||
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; throw new Error('sfu publish-screen http '+res.status); }
|
||||
catch(e){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; fsm.send('FAILED', { error: e.message }); fsm.send('DONE'); throw e; }
|
||||
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; fsm.send('FAILED', { error: 'blocked' }); fsm.send('DONE'); handleBlocked('publish-screen'); return; }
|
||||
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; fsm.send('FAILED', { error: 'http '+res.status }); fsm.send('DONE'); throw new Error('sfu publish-screen http '+res.status); }
|
||||
const ans = await res.json();
|
||||
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
||||
sfuScreenPC = pc; sfuScreenPeerID = ans.peer_id;
|
||||
fsm.send('NEGOTIATED', { pc, peerID: ans.peer_id });
|
||||
/* raise the RTP-level caps: 6 Mbps for video (high-detail 1080p screen),
|
||||
* 510 kbps for audio (Opus spec ceiling, transparent stereo). The codec-level
|
||||
* cap was already raised via preferStereoOpus(). */
|
||||
|
|
@ -2594,6 +2616,8 @@ async function sfuRebuildScreenPC(){
|
|||
|
||||
async function sfuUnpublishScreen(){
|
||||
if (!sfuScreenPC && !sfuScreenStream) return;
|
||||
const fsm = roomMachines.pubs.screen;
|
||||
fsm.send('STOP');
|
||||
const pid = sfuScreenPeerID;
|
||||
if (myKeys) removeScreenTile(myKeys.pubHex);
|
||||
if (sfuScreenStream){ sfuScreenStream.getTracks().forEach(t=>t.stop()); sfuScreenStream = null; }
|
||||
|
|
@ -2601,6 +2625,7 @@ async function sfuUnpublishScreen(){
|
|||
if (pid && roomID){
|
||||
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
|
||||
}
|
||||
fsm.send('DONE');
|
||||
$('btn-screen-share').classList.remove('hidden');
|
||||
$('btn-screen-stop').classList.add('hidden');
|
||||
logLine('', 'screen share stopped');
|
||||
|
|
@ -2689,6 +2714,8 @@ async function sfuUnpublishGame(){
|
|||
/* ----- camera publish (kind=camera) ----- */
|
||||
async function sfuPublishCamera(){
|
||||
if (sfuCameraPC || !myKeys || !roomID) return;
|
||||
const fsm = roomMachines.pubs.camera;
|
||||
fsm.send('START');
|
||||
let stream;
|
||||
/* aspectRatio:{ideal: 16/9} tells Android Chrome / Firefox to capture
|
||||
* in landscape regardless of the device's current screen orientation.
|
||||
|
|
@ -2706,7 +2733,8 @@ async function sfuPublishCamera(){
|
|||
* speaker can choose camera-on while still using a different audio
|
||||
* input (monitor source, music mode, etc) */
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false });
|
||||
} catch(e){ logLine('err','camera open cancelled: '+e.message); return; }
|
||||
} catch(e){ logLine('err','camera open cancelled: '+e.message); fsm.send('FAILED', { error: e.message }); fsm.send('DONE'); return; }
|
||||
fsm.send('ACQUIRED', { stream });
|
||||
sfuCameraStream = stream;
|
||||
const pc = new RTCPeerConnection(rtcConfig);
|
||||
for (const tr of stream.getTracks()){
|
||||
|
|
@ -2722,11 +2750,12 @@ async function sfuPublishCamera(){
|
|||
try { res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ sdp: pc.localDescription.sdp }) }); }
|
||||
catch(e){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; throw e; }
|
||||
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; handleBlocked('publish-camera'); return; }
|
||||
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; throw new Error('sfu publish-camera http '+res.status); }
|
||||
if (res.status === 403){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; fsm.send('FAILED', { error: 'blocked' }); fsm.send('DONE'); handleBlocked('publish-camera'); return; }
|
||||
if (!res.ok){ pc.close(); stream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; fsm.send('FAILED', { error: 'http '+res.status }); fsm.send('DONE'); throw new Error('sfu publish-camera http '+res.status); }
|
||||
const ans = await res.json();
|
||||
await pc.setRemoteDescription({ type:'answer', sdp: ans.sdp });
|
||||
sfuCameraPC = pc; sfuCameraPeerID = ans.peer_id;
|
||||
fsm.send('NEGOTIATED', { pc, peerID: ans.peer_id });
|
||||
/* 1.5 Mbps is plenty for 720p30 face cam — keeps the screen-share
|
||||
* headroom intact when both are live */
|
||||
for (const s of pc.getSenders()){
|
||||
|
|
@ -2734,7 +2763,7 @@ async function sfuPublishCamera(){
|
|||
}
|
||||
watchPublishPC(pc, 'camera', sfuPublishCamera,
|
||||
() => sfuCameraPC === pc,
|
||||
() => { sfuCameraPC = null; sfuCameraPeerID = null; sfuCameraStream = null; });
|
||||
() => { sfuCameraPC = null; sfuCameraPeerID = null; sfuCameraStream = null; fsm.send('STOP'); fsm.send('DONE'); });
|
||||
logLine('', 'sfu: camera on as '+sfuCameraPeerID);
|
||||
renderCameraTile(myKeys.pubHex, stream, { local: true });
|
||||
$('btn-camera-share').classList.add('hidden');
|
||||
|
|
@ -2743,6 +2772,8 @@ async function sfuPublishCamera(){
|
|||
}
|
||||
async function sfuUnpublishCamera(){
|
||||
if (!sfuCameraPC && !sfuCameraStream) return;
|
||||
const fsm = roomMachines.pubs.camera;
|
||||
fsm.send('STOP');
|
||||
const pid = sfuCameraPeerID;
|
||||
if (myKeys) removeCameraTile(myKeys.pubHex);
|
||||
if (sfuCameraStream){ sfuCameraStream.getTracks().forEach(t=>t.stop()); sfuCameraStream = null; }
|
||||
|
|
@ -2750,6 +2781,7 @@ async function sfuUnpublishCamera(){
|
|||
if (pid && roomID){
|
||||
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
|
||||
}
|
||||
fsm.send('DONE');
|
||||
$('btn-camera-share').classList.remove('hidden');
|
||||
$('btn-camera-stop').classList.add('hidden');
|
||||
logLine('', 'camera off');
|
||||
|
|
@ -2820,12 +2852,15 @@ if (window.screen && window.screen.orientation && window.screen.orientation.addE
|
|||
|
||||
async function sfuUnpublish(){
|
||||
if (!sfuPubPC) return;
|
||||
const fsm = roomMachines.pubs.mic;
|
||||
fsm.send('STOP');
|
||||
const pid = sfuPubPeerID;
|
||||
try { sfuPubPC.close(); } catch(_){}
|
||||
sfuPubPC = null; sfuPubPeerID = null;
|
||||
if (pid && roomID){
|
||||
try { await fetch(SFU_BASE + '/unpublish?room=' + encodeURIComponent(roomID) + '&peer=' + pid, { method:'POST' }); } catch(_){}
|
||||
}
|
||||
fsm.send('DONE');
|
||||
}
|
||||
|
||||
/* handleRemoteSfuTrack — receive-side mesh state machine. Called once
|
||||
|
|
@ -3653,6 +3688,20 @@ roomMachines.call.observe(({ state, prev, ctx }) => {
|
|||
try { applyCallStateUI(state, prev, ctx); } catch(e){ logLine('err','call UI observer: '+e.message); }
|
||||
});
|
||||
|
||||
/* Publish-FSM trace observers — one per kind (mic / screen / camera).
|
||||
* Logs every transition so the page log shows the publish lifecycle
|
||||
* synchronously with the imperative sfuPublish* / sfuUnpublish* flow.
|
||||
* Future side-effect migrations can hang off these observers (the
|
||||
* imperative code path will shrink as transitions take over). */
|
||||
for (const kind of ['mic', 'screen', 'camera']){
|
||||
const m = roomMachines.pubs[kind];
|
||||
if (!m) continue;
|
||||
m.observe(({ state, prev, ev }) => {
|
||||
if (prev === null || state === prev) return;
|
||||
logLine('', 'pub.'+kind+': '+prev+' → '+state+(ev && ev.type ? ' ['+ev.type+']' : ''));
|
||||
});
|
||||
}
|
||||
|
||||
let ws = null, wantConnected = false, sigKey = null, sigReconnect = null;
|
||||
|
||||
function send(obj){ if (ws && ws.readyState===1) ws.send(JSON.stringify(obj)); }
|
||||
|
|
@ -5719,8 +5768,8 @@ logLine('', 'ready — pick a handle, type a rendezvous code, enter the space');
|
|||
|
||||
<footer style="margin:2.2rem auto 0;font-size:0.65rem;color:#999;line-height:1.7;word-break:break-all;font-family:monospace">
|
||||
<span id="pi-seal" style="color:#777;cursor:default;user-select:none" title="">page integrity</span> · built <span class="stamp-date">2026-06-04</span><br>
|
||||
md5 <span class="stamp-md5">da9f26a202e920b3ee45134f748d4132</span><br>
|
||||
sha256 <span class="stamp-sha">8b14d4543a3123ba78e2edb87ca1e55373c7b336506c791ee2f8151adc91d2c2</span><br>
|
||||
md5 <span class="stamp-md5">bf20196804c9e6115349da69eae1a7ce</span><br>
|
||||
sha256 <span class="stamp-sha">19a14b121c397a48bce3dacec5ef2f2c7b35d4112e5f959339e7072a612367bb</span><br>
|
||||
<span style="color:#bbb">hashes are of this page with these two fields zeroed — to verify, blank them and re-hash</span><br>
|
||||
<span style="color:#bbb">one self-contained file — <strong>save a copy</strong> and verify against these hashes; point at your own servers with ?signal= and ?turncred=, or <a href="host-your-own.html" style="color:#999">host your own community</a></span>
|
||||
</footer>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue