lesavka: host no-upload receiver observer at dedicated HTTPS path
Some checks failed
Tests / Declarative: Post Actions failed: 42, skipped: 89, passed: 4273
Some checks failed
Tests / Declarative: Post Actions failed: 42, skipped: 89, passed: 4273
This commit is contained in:
parent
d114ce71e8
commit
e035cadb4d
28
services/bstein-dev-home/av-observer/activity-worklet.js
Normal file
28
services/bstein-dev-home/av-observer/activity-worklet.js
Normal file
@ -0,0 +1,28 @@
|
||||
// A cumulative, one-credit metadata reporter: never copies or sends audio.
|
||||
class Activity extends AudioWorkletProcessor {
|
||||
constructor() {
|
||||
super(); this.frames=0; this.inputFrames=0; this.zeroFrames=0;
|
||||
this.energy=0; this.peak=0; this.credit=true; this.next=0; this.stopped=false;
|
||||
this.port.onmessage=({data})=>{ if(data==='ack')this.credit=true; if(data==='stop')this.stopped=true; };
|
||||
}
|
||||
process(inputs,outputs) {
|
||||
if(this.stopped || this.frames >= sampleRate*125) return false;
|
||||
const block=outputs[0]?.[0]?.length ?? 128;
|
||||
const samples=inputs[0]?.[0];
|
||||
this.frames+=block;
|
||||
if(samples) for(const value of samples) {
|
||||
this.inputFrames++; this.energy+=value*value;
|
||||
this.peak=Math.max(this.peak,Math.abs(value));
|
||||
if(value===0)this.zeroFrames++;
|
||||
}
|
||||
// Output is left at zero: no monitoring/playback or acoustic feedback.
|
||||
if(this.credit && this.frames>=this.next) {
|
||||
this.port.postMessage({inputFrames:this.inputFrames,processedFrames:this.frames,
|
||||
exactZeroFrames:this.zeroFrames,rms:this.inputFrames ? Math.sqrt(this.energy/this.inputFrames) : 0,
|
||||
peak:this.peak,sampleRate});
|
||||
this.credit=false; this.next=this.frames+sampleRate;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('av-activity',Activity);
|
||||
53
services/bstein-dev-home/av-observer/deployment.yaml
Normal file
53
services/bstein-dev-home/av-observer/deployment.yaml
Normal file
@ -0,0 +1,53 @@
|
||||
# Static assets only; no API, uploads, credentials or outbound connections.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: lesavka-av-observer
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 2
|
||||
selector:
|
||||
matchLabels: {app: lesavka-av-observer}
|
||||
template:
|
||||
metadata:
|
||||
labels: {app: lesavka-av-observer}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
nodeSelector:
|
||||
kubernetes.io/arch: arm64
|
||||
node-role.kubernetes.io/worker: "true"
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 101
|
||||
runAsGroup: 101
|
||||
fsGroup: 101
|
||||
seccompProfile: {type: RuntimeDefault}
|
||||
containers:
|
||||
- name: static
|
||||
image: nginx:1.27.5-alpine@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10
|
||||
command: [/usr/sbin/nginx]
|
||||
args: [-c, /etc/nginx/observer/nginx.conf, -g, "daemon off;"]
|
||||
ports: [{name: http, containerPort: 8080}]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities: {drop: [ALL]}
|
||||
resources:
|
||||
requests: {cpu: 10m, memory: 16Mi}
|
||||
limits: {cpu: 100m, memory: 64Mi}
|
||||
readinessProbe:
|
||||
httpGet: {path: /lesavka-av-test/, port: http}
|
||||
livenessProbe:
|
||||
httpGet: {path: /lesavka-av-test/, port: http}
|
||||
initialDelaySeconds: 10
|
||||
volumeMounts:
|
||||
- {name: config, mountPath: /etc/nginx/observer, readOnly: true}
|
||||
- {name: assets, mountPath: /usr/share/nginx/html/lesavka-av-test, readOnly: true}
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
volumes:
|
||||
- name: config
|
||||
configMap: {name: lesavka-av-observer-nginx}
|
||||
- name: assets
|
||||
configMap: {name: lesavka-av-observer-assets}
|
||||
- name: tmp
|
||||
emptyDir: {medium: Memory, sizeLimit: 8Mi}
|
||||
18
services/bstein-dev-home/av-observer/index.html
Normal file
18
services/bstein-dev-home/av-observer/index.html
Normal file
@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'none'; media-src 'self' blob:; style-src 'self'; object-src 'none'; base-uri 'none'">
|
||||
<title>Lesavka — bundled A/V observer</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<main><h1>Bundled A/V observer</h1>
|
||||
<p>No recording, uploads or saved camera/microphone content. Select the RCT's Lesavka video and microphone devices.</p>
|
||||
<div><button id="devices">Choose devices…</button>
|
||||
<label>Video <select id="video-device"></select></label>
|
||||
<label>Microphone <select id="audio-device"></select></label></div>
|
||||
<div><button id="start" disabled>Start 2-minute check</button><button id="stop" disabled>Stop</button><button id="export" disabled>Save metrics</button></div>
|
||||
<p id="status" role="status">Stopped</p>
|
||||
<video id="preview" autoplay muted playsinline></video>
|
||||
<pre id="metrics">No observations.</pre>
|
||||
<p>Sync and capture-to-RCT freshness: <strong>not measured by this observer</strong>.
|
||||
Video callback gaps can include browser scheduling. Silence is not proof of audio loss.
|
||||
Use the qualified paired-marker/clock-bounded probe for release acceptance.</p>
|
||||
</main><script type="module" src="observer.js"></script></html>
|
||||
23
services/bstein-dev-home/av-observer/ingress.yaml
Normal file
23
services/bstein-dev-home/av-observer/ingress.yaml
Normal file
@ -0,0 +1,23 @@
|
||||
# Reuse the existing domain/certificate, without changing its homepage route.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: lesavka-av-observer
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts: [bstein.dev]
|
||||
secretName: bstein-dev-home-tls
|
||||
rules:
|
||||
- host: bstein.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /lesavka-av-test
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: lesavka-av-observer
|
||||
port: {number: 80}
|
||||
17
services/bstein-dev-home/av-observer/kustomization.yaml
Normal file
17
services/bstein-dev-home/av-observer/kustomization.yaml
Normal file
@ -0,0 +1,17 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- ingress.yaml
|
||||
- network-policy.yaml
|
||||
configMapGenerator:
|
||||
- name: lesavka-av-observer-nginx
|
||||
files: [nginx.conf]
|
||||
- name: lesavka-av-observer-assets
|
||||
files:
|
||||
- index.html
|
||||
- observer.js
|
||||
- metrics.js
|
||||
- activity-worklet.js
|
||||
- style.css
|
||||
30
services/bstein-dev-home/av-observer/metrics.js
Normal file
30
services/bstein-dev-home/av-observer/metrics.js
Normal file
@ -0,0 +1,30 @@
|
||||
// Metadata only. No frames or PCM are retained. These are OBSERVER metrics,
|
||||
// never an automatically passing sync/freshness/artifact report.
|
||||
export class VideoMetrics {
|
||||
constructor() { this.last = null; this.intervals = []; this.callbacks = 0; this.unobserved = 0; this.resets = 0; }
|
||||
observe(timeMs, presentedFrames) {
|
||||
if (!Number.isFinite(timeMs) || !Number.isSafeInteger(presentedFrames) || presentedFrames < 0) return;
|
||||
if (this.last) {
|
||||
if (timeMs <= this.last.timeMs || presentedFrames <= this.last.presentedFrames) this.resets++;
|
||||
else {
|
||||
if (this.intervals.length < 15000) this.intervals.push(timeMs-this.last.timeMs);
|
||||
this.unobserved += Math.max(0,presentedFrames-this.last.presentedFrames-1);
|
||||
}
|
||||
}
|
||||
this.last = {timeMs,presentedFrames}; this.callbacks++;
|
||||
}
|
||||
summary() {
|
||||
const sorted = [...this.intervals].sort((a,b)=>a-b);
|
||||
return {callbacks:this.callbacks,unobservedPresentedFrames:this.unobserved,clockOrCounterResets:this.resets,
|
||||
callbackIntervalP95Ms:sorted.length ? sorted[Math.ceil(sorted.length*.95)-1] : null,
|
||||
callbackGapMaxMs:sorted.at(-1) ?? null,
|
||||
note:"Browser presentation callbacks; gaps are not proof of transport loss or corrupted video."};
|
||||
}
|
||||
}
|
||||
|
||||
export function receipt(video,audio,settings,elapsedMs,reason) {
|
||||
return {schema:"lesavka.rct-av-observer.v1",kind:"diagnostic-only",reason,elapsedMs,
|
||||
settings,video:video.summary(),audio,
|
||||
syncMeasured:false,freshnessMeasured:false,artifactsMeasured:false,
|
||||
releaseEligible:false,recordedMedia:false};
|
||||
}
|
||||
12
services/bstein-dev-home/av-observer/network-policy.yaml
Normal file
12
services/bstein-dev-home/av-observer/network-policy.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# The observer only serves static files; it has no reason to initiate traffic.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: lesavka-av-observer
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels: {app: lesavka-av-observer}
|
||||
policyTypes: [Ingress, Egress]
|
||||
ingress:
|
||||
- ports: [{protocol: TCP, port: 8080}]
|
||||
egress: []
|
||||
35
services/bstein-dev-home/av-observer/nginx.conf
Normal file
35
services/bstein-dev-home/av-observer/nginx.conf
Normal file
@ -0,0 +1,35 @@
|
||||
worker_processes 1;
|
||||
pid /tmp/nginx.pid;
|
||||
error_log /dev/stderr warn;
|
||||
events { worker_connections 128; }
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
access_log off;
|
||||
server_tokens off;
|
||||
client_max_body_size 1k;
|
||||
client_body_temp_path /tmp/client;
|
||||
proxy_temp_path /tmp/proxy;
|
||||
fastcgi_temp_path /tmp/fastcgi;
|
||||
uwsgi_temp_path /tmp/uwsgi;
|
||||
scgi_temp_path /tmp/scgi;
|
||||
server {
|
||||
listen 8080;
|
||||
absolute_redirect off;
|
||||
root /usr/share/nginx/html;
|
||||
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Permissions-Policy "camera=(self), microphone=(self), geolocation=(), display-capture=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; media-src 'self' blob:; connect-src 'none'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
|
||||
location = /lesavka-av-test { return 308 /lesavka-av-test/; }
|
||||
location = /lesavka-av-test/ { try_files /lesavka-av-test/index.html =404; }
|
||||
location ~ ^/lesavka-av-test/(index\.html|observer\.js|metrics\.js|activity-worklet\.js|style\.css)$ {
|
||||
try_files $uri =404;
|
||||
}
|
||||
location / { return 404; }
|
||||
}
|
||||
}
|
||||
117
services/bstein-dev-home/av-observer/observer.js
Normal file
117
services/bstein-dev-home/av-observer/observer.js
Normal file
@ -0,0 +1,117 @@
|
||||
import {VideoMetrics,receipt} from './metrics.js';
|
||||
const el=id=>document.getElementById(id);
|
||||
let generation=0, current=null, last=null;
|
||||
const stopTracks=stream=>stream?.getTracks().forEach(track=>track.stop());
|
||||
function release(run) {
|
||||
if(run.released)return;
|
||||
run.released=true;
|
||||
clearTimeout(run.timer); clearTimeout(run.setupTimer);
|
||||
if(run.callback)el('preview').cancelVideoFrameCallback(run.callback);
|
||||
run.node?.port.postMessage('stop'); run.node?.disconnect(); run.source?.disconnect();
|
||||
stopTracks(run.stream);
|
||||
// Do not wait on a browser AudioContext close before fencing a stopped run.
|
||||
run.context?.close().catch(()=>{});
|
||||
}
|
||||
function stop(reason='operator') {
|
||||
generation++;
|
||||
const run=current; current=null;
|
||||
if(run) {
|
||||
release(run);
|
||||
last=receipt(run.video,run.audio,run.settings,performance.now()-run.started,reason);
|
||||
el('metrics').textContent=JSON.stringify(last,null,2);
|
||||
el('export').disabled=false;
|
||||
}
|
||||
el('preview').srcObject=null;
|
||||
el('start').disabled=!el('audio-device').value || !el('video-device').value;
|
||||
el('stop').disabled=true; el('devices').disabled=false;
|
||||
el('status').textContent=`Stopped · ${reason}`;
|
||||
}
|
||||
el('devices').onclick=async()=>{
|
||||
stop('selecting devices');
|
||||
const ticket=++generation;
|
||||
el('devices').disabled=true; el('start').disabled=true; el('stop').disabled=false;
|
||||
el('status').textContent='Requesting device permission…';
|
||||
const timer=setTimeout(()=>{if(generation===ticket)stop('permission timeout');},20000);
|
||||
try {
|
||||
const stream=await navigator.mediaDevices.getUserMedia({audio:true,video:true});
|
||||
stopTracks(stream);
|
||||
if(generation!==ticket)return;
|
||||
const devices=await navigator.mediaDevices.enumerateDevices();
|
||||
if(generation!==ticket)return;
|
||||
for(const [id,kind] of [['video-device','videoinput'],['audio-device','audioinput']]) {
|
||||
const select=el(id), saved=select.value;
|
||||
select.replaceChildren(new Option('Select Lesavka device',''));
|
||||
for(const d of devices.filter(d=>d.kind===kind)) select.add(new Option(d.label||'Unnamed device',d.deviceId));
|
||||
select.value=saved;
|
||||
}
|
||||
el('status').textContent='Select both Lesavka devices, then Start.';
|
||||
el('devices').disabled=false; el('stop').disabled=true;
|
||||
} catch { if(generation===ticket)stop('device permission unavailable'); }
|
||||
finally {clearTimeout(timer);}
|
||||
};
|
||||
for(const id of ['audio-device','video-device']) el(id).onchange=()=>{
|
||||
if(current)stop('device selection changed');
|
||||
el('start').disabled=!el('audio-device').value||!el('video-device').value;
|
||||
};
|
||||
el('start').onclick=async()=>{
|
||||
if(current || !el('audio-device').value || !el('video-device').value)return;
|
||||
if(!el('preview').requestVideoFrameCallback || !window.AudioWorkletNode) {
|
||||
el('status').textContent='This browser lacks the required observation APIs.';return;
|
||||
}
|
||||
const ticket=++generation;
|
||||
const run={started:performance.now(),video:new VideoMetrics(),audio:null,settings:{}};
|
||||
current=run; last=null;
|
||||
el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true;
|
||||
el('status').textContent='Starting…';
|
||||
run.setupTimer=setTimeout(()=>{if(current===run)stop('setup timeout');},20000);
|
||||
try {
|
||||
const stream=await navigator.mediaDevices.getUserMedia({
|
||||
video:{deviceId:{exact:el('video-device').value}},
|
||||
audio:{deviceId:{exact:el('audio-device').value},echoCancellation:false,noiseSuppression:false,autoGainControl:false},
|
||||
});
|
||||
if(generation!==ticket){stopTracks(stream);return;}
|
||||
run.stream=stream;
|
||||
for(const track of stream.getTracks())track.onended=()=>{if(current===run)stop('device ended');};
|
||||
const vs=stream.getVideoTracks()[0].getSettings(),as=stream.getAudioTracks()[0].getSettings();
|
||||
run.settings={video:{width:vs.width,height:vs.height,frameRate:vs.frameRate},
|
||||
audio:{sampleRate:as.sampleRate,channelCount:as.channelCount,echoCancellation:as.echoCancellation,
|
||||
noiseSuppression:as.noiseSuppression,autoGainControl:as.autoGainControl}};
|
||||
run.context=new AudioContext();
|
||||
await run.context.audioWorklet.addModule('activity-worklet.js');
|
||||
if(generation!==ticket)return;
|
||||
run.node=new AudioWorkletNode(run.context,'av-activity');
|
||||
run.node.onprocessorerror=()=>{if(current===run)stop('audio observer error');};
|
||||
run.node.port.onmessage=({data})=>{
|
||||
if(current!==run)return;
|
||||
run.audio=data; run.node.port.postMessage('ack');
|
||||
el('metrics').textContent=JSON.stringify(receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing'),null,2);
|
||||
};
|
||||
run.source=run.context.createMediaStreamSource(new MediaStream(stream.getAudioTracks()));
|
||||
run.source.connect(run.node);run.node.connect(run.context.destination);
|
||||
await run.context.resume();
|
||||
if(generation!==ticket)return;
|
||||
el('preview').srcObject=stream;
|
||||
await el('preview').play();
|
||||
if(generation!==ticket)return;
|
||||
clearTimeout(run.setupTimer);
|
||||
run.started=performance.now();
|
||||
function frame(now,metadata) {
|
||||
if(current!==run)return;
|
||||
run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames);
|
||||
run.callback=el('preview').requestVideoFrameCallback(frame);
|
||||
}
|
||||
run.callback=el('preview').requestVideoFrameCallback(frame);
|
||||
run.timer=setTimeout(()=>{if(current===run)stop('120 seconds complete');},120000);
|
||||
el('status').textContent='Observing both devices · no recording';
|
||||
} catch { if(current===run)stop('capture/setup failed'); }
|
||||
finally { if(current!==run)release(run); }
|
||||
};
|
||||
el('stop').onclick=()=>stop();
|
||||
el('export').onclick=()=>{
|
||||
if(!last)return;
|
||||
const url=URL.createObjectURL(new Blob([JSON.stringify(last,null,2)],{type:'application/json'}));
|
||||
const link=document.createElement('a');link.href=url;link.download='lesavka-av-observer.json';link.click();
|
||||
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
};
|
||||
window.addEventListener('pagehide',()=>stop('page closed'));
|
||||
document.addEventListener('visibilitychange',()=>{if(document.hidden&¤t)stop('page hidden; timing invalid');});
|
||||
7
services/bstein-dev-home/av-observer/service.yaml
Normal file
7
services/bstein-dev-home/av-observer/service.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: lesavka-av-observer
|
||||
spec:
|
||||
selector: {app: lesavka-av-observer}
|
||||
ports: [{name: http, port: 80, targetPort: http}]
|
||||
7
services/bstein-dev-home/av-observer/style.css
Normal file
7
services/bstein-dev-home/av-observer/style.css
Normal file
@ -0,0 +1,7 @@
|
||||
:root { color-scheme: dark; font: 15px system-ui,sans-serif; }
|
||||
body { margin: 24px; background: #171b22; color: #e7edf6; }
|
||||
main { max-width: 1000px; margin: auto; }
|
||||
button,select { padding: 8px; margin: 4px; max-width: 100%; }
|
||||
label { display: inline-block; }
|
||||
video { width: 100%; max-height: 55vh; background: #000; }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
@ -18,6 +18,7 @@ resources:
|
||||
- vaultwarden-cred-sync-cronjob.yaml
|
||||
- validation-jobs/portal-onboarding-e2e-test-job.yaml
|
||||
- ingress.yaml
|
||||
- av-observer
|
||||
images:
|
||||
- name: registry.bstein.dev/bstein/bstein-dev-home-frontend
|
||||
newTag: 0.1.1-541 # {"$imagepolicy": "bstein-dev-home:bstein-dev-home-frontend:tag"}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user