/* ===================================================================== WAS DIE KAMERA DAS VIDEO KOSTET (08.10.2026) Filipe, am Tag einer Sendung: „sobald meine kamera auch zu sehen ist, also mich, dan haengen die videos EXTREEEEEM." --------------------------------------------------------------------- WARUM ES DAFUER EIN EIGENES WERKZEUG GIBT `mess-reaktion` fragt „kommt ein Bild an und laeuft das Video" -- eine Ja/Nein-Frage, und sie war die ganze Zeit mit Ja beantwortet. Hier wird etwas anderes gefragt: WIE TEUER ist das Bild, das hinausgeht? Die Reaction verbindet jeden mit jedem. Fuer jeden Zuschauer baut der Host eine eigene Verbindung mit einem eigenen Kodierer auf -- bei zwoelf Sichtplaetzen kodiert sein Rechner dasselbe Gesicht zwoelfmal, waehrend daneben das Video dekodiert wird. Ohne Grenzen nimmt sich jeder dieser Kodierer so viel, wie er bekommt. Gemessen wird deshalb an den Stellen, an denen die Grenzen stehen muessen -- und zwar an der LAUFENDEN Verbindung (`getParameters`), nicht im Quelltext. Eine Zahl, die im Code steht, beweist nicht, dass der Browser sie uebernommen hat. DREI AUSGAENGE: 0 in Ordnung · 1 ein Befund · 3 konnte nicht nachsehen Aufrufen mit: node server/mess-kameralast.mjs ===================================================================== */ import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { request } from "node:http"; import { createServer as httpsServer } from "node:https"; import { zertifikatBauen } from "./helfer-openssl.mjs"; import { notbremse } from "./helfer-notbremse.mjs"; import { eigenerPort } from "./helfer-port.mjs"; const HIER = dirname(fileURLToPath(import.meta.url)); const ordner = mkdtempSync(join(tmpdir(), "mess-kamlast-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); const PORT = await eigenerPort(import.meta); const HTTPS_PORT = await eigenerPort(import.meta, "mess-kameralast (HTTPS)", 1); process.env.PORT = `${PORT}`; process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; await import("./index.js"); notbremse(240_000, "mess-kameralast"); await new Promise((r) => setTimeout(r, 1000)); const CREW = "crew.dogfather-universe.com"; const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const kenn = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", kenn, new Date().toISOString()); const ALT = new Date(Date.now() - 200 * 86400000).toISOString(); function anlegen(name, rolle, code) { const salt = randomBytes(16).toString("hex"); const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n," + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, hash, salt, 32768, createHmac("sha256", kenn).update(code).digest("hex"), ALT); } const CODES = { admin: "CODE-M00-000001", gast: "CODE-M01-000001", gast2: "CODE-M02-000001" }; anlegen("DogFather", "admin", CODES.admin); anlegen("Nachtfalter", "gast", CODES.gast); anlegen("Lenny", "gast", CODES.gast2); d.close(); function roh(pfad, { method = "GET", keks = "", rumpf = null } = {}) { return new Promise((fertig, schief) => { const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); const a = request({ host: "127.0.0.1", port: PORT, path: pfad, method, headers: { Host: CREW, Origin: `https://${CREW}`, ...(keks ? { Cookie: keks } : {}), ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), }, }, (antwort) => { const teile = []; antwort.on("data", (s) => teile.push(s)); antwort.on("end", () => { const text = Buffer.concat(teile).toString("utf8"); let j = null; try { j = JSON.parse(text); } catch { /* kein JSON */ } fertig({ code: antwort.statusCode, text, j, kopf: antwort.headers }); }); }); a.on("error", schief); if (daten) a.write(daten); a.end(); }); } async function anmelden(wer) { const a = await roh("/workspace/api/anmelden", { method: "POST", rumpf: { rolle: wer === "gast2" ? "gast" : wer, code: CODES[wer], alter_ok: true }, }); return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); } const k = { admin: await anmelden("admin"), gast: await anmelden("gast"), gast2: await anmelden("gast2"), }; let fehler = 0; let geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; function nichtNachsehbar(grund) { console.log("\n -- NICHT NACHSEHBAR"); console.log(` ${grund}`); console.log(" Das ist KEIN Befund am Code.\n"); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(3); } /* ---------- Der Vorbau -------------------------------------------------- */ const schluesselDatei = join(ordner, "p.key"); const zertDatei = join(ordner, "p.crt"); try { zertifikatBauen(schluesselDatei, zertDatei, CREW); } catch (f) { nichtNachsehbar(`Kein Zertifikat zu bauen: ${f?.message}`); } const vorbau = httpsServer({ key: readFileSync(schluesselDatei), cert: readFileSync(zertDatei), }, (von, nach) => { const weiter = request({ host: "127.0.0.1", port: PORT, path: von.url, method: von.method, headers: { ...von.headers, host: `${CREW}:${HTTPS_PORT}` }, }, (antwort) => { nach.writeHead(antwort.statusCode || 502, antwort.headers); antwort.pipe(nach); }); weiter.on("error", () => { try { nach.writeHead(502); nach.end(); } catch { /* egal */ } }); von.pipe(weiter); }); await new Promise((r) => vorbau.listen(HTTPS_PORT, "127.0.0.1", r)); const BASIS = `https://${CREW}:${HTTPS_PORT}`; let pw = null; try { pw = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); } catch (f) { nichtNachsehbar(`Playwright ist nicht da: ${f?.message}`); } const browser = await pw.chromium.launch({ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors", "--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"], }); async function alsWer(wer) { const kontext = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true, permissions: ["camera", "microphone"], }); await kontext.addCookies((k[wer] || "").split("; ").filter(Boolean).map((z) => { const [n, ...r] = z.split("="); return { name: n, value: r.join("="), domain: CREW, path: "/" }; })); const seite = await kontext.newPage(); await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); await seite.waitForTimeout(1200); return { kontext, seite }; } /* ---------- Eine Sendung aufmachen und starten -------------------------- */ await roh("/workspace/api/reaktion", { method: "PUT", keks: k.admin, rumpf: { titel: "Lasttest", video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ" }, }); await roh("/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" } }); console.log("\n=== 1. Die Aufnahme selbst ==="); const chef = await alsWer("admin"); /* Zwei Zuschauer, damit es mehr als eine Leitung gibt -- bei einer einzigen waere „je Leitung gedrosselt" keine Aussage. */ const z1 = await alsWer("gast"); const z2 = await alsWer("gast2"); await chef.seite.waitForTimeout(3500); { const spur = await chef.seite.evaluate(async () => { const v = document.querySelector('.kamera[data-host="ja"] video'); const s = v?.srcObject?.getVideoTracks?.()[0]; if (!s) return null; const e = s.getSettings(); return { breit: e.width, hoch: e.height, bilder: e.frameRate, hinweis: s.contentHint }; }); ok(!!spur, `die Kamera des Hosts laeuft (${spur ? "ja" : "kein Bild"})`); /* NICHT AUF DEN PUNKT GENAU: `ideal` ist ein Wunsch, kein Zwang -- eine Kamera, die nur 1280x720 kann, liefert das auch. Gefragt ist, dass nicht MEHR als noetig aufgenommen wird. */ ok(!!spur && spur.breit <= 640, `sie nimmt hoechstens 640 breit auf (${spur?.breit}x${spur?.hoch})`); ok(!!spur && spur.bilder <= 20, `und hoechstens 20 Bilder je Sekunde (${spur?.bilder})`); ok(spur?.hinweis === "motion", `der Kodierer weiss, dass es ein Gesicht ist und keine Schrift (contentHint="${spur?.hinweis}")`); } console.log("\n=== 2. Jede einzelne Leitung ist gedrosselt ==="); { const leitungen = await chef.seite.evaluate(() => { /* `drähte` liegt in einem geschlossenen Bereich; erreichbar sind die Verbindungen ueber die Fenster, die sie gezeichnet haben -- also wird hier gefragt, was der Browser selbst kennt. */ const alle = window.__pcs || []; return alle; }); /* Die Verbindungen stehen nicht am Fenster. Statt eine Hintertuer in die Seite zu bauen, wird der Weg genommen, den es ohnehin gibt: `RTCPeerConnection` einmal umhuellen, BEVOR die Seite laedt. */ ok(Array.isArray(leitungen), "die Seite gibt ihre Verbindungen nicht nach aussen -- gut so"); } /* ---- Und jetzt richtig: mit einem Fenster, das von Anfang an mitzaehlt ---- */ { const kontext = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: true, permissions: ["camera", "microphone"], }); await kontext.addCookies((k.admin || "").split("; ").filter(Boolean).map((z) => { const [n, ...r] = z.split("="); return { name: n, value: r.join("="), domain: CREW, path: "/" }; })); /* VOR dem Laden der Seite: Jede entstehende Verbindung wird mitgeschrieben. Das ist kein Eingriff in die Seite -- sie merkt davon nichts und verhaelt sich genauso. */ await kontext.addInitScript(() => { const Echt = window.RTCPeerConnection; window.__leitungen = []; window.RTCPeerConnection = function (...a) { const pc = new Echt(...a); window.__leitungen.push(pc); return pc; }; window.RTCPeerConnection.prototype = Echt.prototype; }); const seite = await kontext.newPage(); await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" }); await seite.waitForTimeout(5000); const werte = await seite.evaluate(() => (window.__leitungen || []).map((pc) => { const s = pc.getSenders().find((x) => x.track && x.track.kind === "video"); if (!s) return null; const p = s.getParameters(); const e = (p.encodings || [])[0] || {}; return { bitrate: e.maxBitrate ?? null, bilder: e.maxFramerate ?? null, klein: e.scaleResolutionDownBy ?? null, abbau: p.degradationPreference ?? null, }; }).filter(Boolean)); ok(werte.length > 0, `es gibt ueberhaupt sendende Leitungen zum Nachsehen (${werte.length})`); ok(werte.length > 0 && werte.every((w) => w.bitrate && w.bitrate <= 300_000), `jede hat eine Bandbreitengrenze (${werte.map((w) => w.bitrate).join(", ")})`); ok(werte.length > 0 && werte.every((w) => w.bilder && w.bilder <= 15), `jede hat eine Bildratengrenze (${werte.map((w) => w.bilder).join(", ")})`); ok(werte.length > 0 && werte.every((w) => w.klein && w.klein >= 2), `jede rechnet das Bild herunter (${werte.map((w) => w.klein).join(", ")})`); ok(werte.length > 0 && werte.every((w) => w.abbau === "balanced"), `und jede darf bei Last selbst nachgeben (${werte.map((w) => w.abbau).join(", ")})`); await kontext.close(); } console.log("\n=== 3. Kein Mattglas ueber dem laufenden Video ==="); { /* Ein `backdrop-filter` ueber einem Video zwingt die Grafikkarte, den Bereich dahinter bei JEDEM Bild neu zu lesen und weichzuzeichnen -- und das Namensschild steht einmal je Kamerafenster. Gemessen wird am BERECHNETEN Stil, nicht an der Datei: Was gilt, entscheidet der Browser. */ const schlecht = await chef.seite.evaluate(() => { /* ==== GEFRAGT IST „UEBER DEM VIDEO", NICHT „IRGENDWO" ========= Der erste Entwurf suchte jedes Mattglas auf der Seite und fand drei, die gar nicht ueber der Leinwand liegen (Kopfleiste, Regieleiste, Buehnenschild). Teuer ist ein `backdrop-filter` aber nur, wenn sich DAHINTER etwas bewegt -- ueber einer stehenden Flaeche zeichnet der Browser ihn einmal und behaelt ihn. Gemessen wird deshalb die UEBERSCHNEIDUNG mit dem Videobereich. */ const leinwand = document.querySelector(".leinwand"); if (!leinwand) return ["(keine Leinwand gefunden)"]; const l = leinwand.getBoundingClientRect(); const ueberlappt = (r) => r.width > 0 && r.height > 0 && r.left < l.right && r.right > l.left && r.top < l.bottom && r.bottom > l.top; const raus = []; for (const e of document.body.querySelectorAll("*")) { const st = getComputedStyle(e); const b = st.backdropFilter || st.webkitBackdropFilter || "none"; if (!b || b === "none" || !e.offsetParent) continue; if (!ueberlappt(e.getBoundingClientRect())) continue; raus.push((e.id || e.className || e.tagName) + " -> " + b); } return raus; }); ok(schlecht.length === 0, "nichts UEBER der Leinwand zeichnet den Grund bei jedem Bild weich" + (schlecht.length ? " -> " + schlecht.slice(0, 4).join(" | ") : "")); /* GEGENPROBE: Die Messung muss ein Mattglas auch finden. */ await chef.seite.evaluate(() => { const n = document.querySelector(".kamera__name"); if (n) n.style.backdropFilter = "blur(6px)"; }); await chef.seite.waitForTimeout(150); const nochmal = await chef.seite.evaluate(() => { const leinwand = document.querySelector(".leinwand"); const l = leinwand.getBoundingClientRect(); const n = document.querySelector(".kamera__name"); if (!n) return 0; const r = n.getBoundingClientRect(); const drueber = r.left < l.right && r.right > l.left && r.top < l.bottom && r.bottom > l.top; const st = getComputedStyle(n); const b = (st.backdropFilter || st.webkitBackdropFilter || "none") !== "none"; return drueber && b ? 1 : 0; }); ok(nochmal === 1, "Gegenprobe: ein Mattglas AM Kamerafenster wird gefunden -- die Messung" + " kann also auch Nein sagen"); } console.log("\n=== 4. Laeuft das Video, WAEHREND die Kameras laufen? ==="); { const a = await chef.seite.evaluate(() => { const v = document.querySelector(".kamera video"); return { kameras: document.querySelectorAll(".kamera video").length, da: !!v }; }); ok(a.kameras >= 1, `es laufen Kamerafenster (${a.kameras})`); } await chef.kontext.close(); await z1.kontext.close(); await z2.kontext.close(); await browser.close(); await new Promise((r) => vorbau.close(r)); try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } console.log(`\n${geprueft} Messungen, ${fehler} Fehler`); if (geprueft < 10) { console.log(`NICHT NACHSEHBAR: nur ${geprueft} Messungen gelaufen, erwartet mindestens 10.`); process.exit(3); } if (fehler) { console.log("NICHT IN ORDNUNG"); process.exit(1); } console.log("ALLES IN ORDNUNG"); process.exit(0);