Files
dogfather-universe/server/mess-kameralast.mjs
T
DogFatherGitandClaude Opus 5 e93c4067f9 Das Video haengt nicht mehr, wenn die Kamera laeuft
Filipe, am Tag einer Sendung: „sobald meine kamera auch zu sehen ist,
also mich, dan haengen die videos EXTREEEEEM. wenn das video alleine
nur laeuft dan laeuft es fast perfekt."

Haus: Team Dogi. Zwei Ursachen, und BEIDE sind nur aktiv, wenn
Kameras sichtbar sind -- genau deshalb lief das Video allein sauber.

1. ZWOELF KODIERER OHNE JEDE GRENZE

Die Reaction verbindet jeden mit jedem. Fuer jeden Zuschauer baut der
Host eine eigene Verbindung auf, und jede hat ihren EIGENEN Kodierer:
Bei zwoelf Sichtplaetzen kodiert sein Rechner dasselbe Gesicht
zwoelfmal gleichzeitig, waehrend daneben das Video dekodiert wird.

Und an keinem einzigen Sender stand eine Grenze. `addTrack` ohne ein
Wort zu `maxBitrate`, `maxFramerate`, `scaleResolutionDownBy` oder
`degradationPreference` -- zwoelf Kodierer, die alle gleichzeitig „so
gut wie moeglich" versuchen und dem Video die Rechenzeit wegnehmen.

Jetzt: 220 kbit, 15 Bilder, Aufloesung halbiert (240x180 gehen
hinaus, das Fenster ist 200 Pixel breit), `balanced`. Rund ein
Sechstel der bisherigen Rechenlast. Der geteilte Bildschirm bekommt
eigene Werte -- dort ist die Aufloesung der Zweck und die Bildrate
fast egal.

2. MATTGLAS UEBER EINEM LAUFENDEN VIDEO

`backdrop-filter` zwingt die Grafikkarte, den Bereich DAHINTER neu zu
lesen und weichzuzeichnen. Ueber einer stehenden Flaeche kostet das
einmal etwas; ueber einem laufenden Video bei JEDEM Bild -- auf
derselben Grafikkarte, die das Video dekodiert.

Vier Stellen lagen auf der Leinwand: das Namensschild JEDES
Kamerafensters (bei voller Sendung dreizehnmal), die Tempoanzeige,
die Senderleiste und der Ton-Knopf. Alle vier tragen jetzt einen
deckenderen Grund und keinen Weichzeichner. Dazu `contain: paint` am
Kamerafenster: Ein ankommendes Kamerabild zieht keine Neuzeichnung
der Leinwand mehr nach sich.

EIN RUECKZIEHER, UND ZWAR EIN WICHTIGER

Mein erster Griff war, die Kamera kleiner aufzunehmen (480x360 statt
640x480). `mess-reaktion` meldete daraufhin „Im Vorraum laeuft kein
eigenes Bild" -- eine Warnung, die vorher nicht da war. 640x480 kann
jede Kamera, 480x360 nicht, und `ideal` ist zwar nur ein Wunsch, aber
was dabei herauskommt, entscheidet der Treiber. Am Abend einer Sendung
ist „vielleicht kein Bild" der schlechteste aller Tausche. Die
Aufloesung bleibt deshalb, kleiner gerechnet wird im Kodierer -- dort
ist es nachweislich erlaubt und kann nichts verhindern, was vorher
ging.

GEMESSEN -- mess-kameralast.mjs (neu), 13 Messungen, 0 Fehler

Sie fragt nicht „kommt ein Bild an" (das war immer mit Ja beantwortet),
sondern WIE TEUER das Bild ist, das hinausgeht -- und zwar an der
LAUFENDEN Verbindung ueber `getParameters()`, nicht im Quelltext. Eine
Zahl im Code beweist nicht, dass der Browser sie uebernommen hat.

Zum Mattglas stellt sie die PRAEZISE Frage: Der erste Entwurf suchte
jedes `backdrop-filter` auf der Seite und meldete drei, die gar nicht
ueber der Leinwand liegen (Kopfleiste, Regieleiste, Buehnenschild) --
teuer ist es aber nur, wenn sich dahinter etwas bewegt. Gemessen wird
jetzt die UEBERSCHNEIDUNG mit dem Videobereich; so kamen die zwei
heraus, die ich uebersehen hatte.

  mess-kameralast (neu)   13, 0 Fehler
  mess-reaktion           0 ACHTUNG (mit dem Rueckzieher wieder sauber)
  pruef-reaktion          588, 0 Fehler
  pruef-css-klassen       gruen

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-08 21:58:37 +02:00

371 lines
15 KiB
JavaScript

/* =====================================================================
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);