zebra-report: deploy FSM-driven publish lifecycle (mic/camera/screen)

This commit is contained in:
russell@unturf.com 2026-06-04 13:55:45 -04:00
parent bbffd61d89
commit 1f19037c6f
No known key found for this signature in database

View file

@ -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> &nbsp;·&nbsp; 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>