diff --git a/server/mess-kameralast.mjs b/server/mess-kameralast.mjs new file mode 100644 index 00000000..906f001e --- /dev/null +++ b/server/mess-kameralast.mjs @@ -0,0 +1,370 @@ +/* ===================================================================== + 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); diff --git a/workspace/anleitung.html b/workspace/anleitung.html index 7c368954..a96bfeed 100644 --- a/workspace/anleitung.html +++ b/workspace/anleitung.html @@ -7,17 +7,17 @@ - - - - - + + + + + - - + +
@@ -289,16 +289,16 @@ Und anleitung.js braucht es doppelt: Symbol, Farbe, Name und Adresse jeder Karte kommen von dort, nicht vom Server. --> - + - - - - - - + + + + + +