Filipes Notiz nennt acht: "Host-Steuerung fuer Kamera, Mikrofon,
Video, Gaeste, Lautstaerke, Chat, Layout und Start/Ende."
Nachgezaehlt war sechsmal etwas da und zweimal nichts.
KAMERA
Es gab keinen Weg, das eigene Bild abzuschalten. Wer kurz aufstehen,
trinken oder etwas holen wollte, musste die Sendung verlassen oder
sich dabei filmen lassen.
Jetzt liegt eine SENDERLEISTE ueber dem Bild -- Kamera, Mikro und ein
Pegel. Sie gehoert jedem, der sendet, Host wie Gast: Ein Gast, der
sein eigenes Bild nicht abschalten kann, muesste den Host darum
bitten, und das ist keine Bedienung, sondern eine Bitte.
DAS BILD WIRD AM GERAET ABGESCHALTET (`track.enabled = false`), nicht
am Server: Die Verbindung bleibt stehen, der Ton laeuft weiter, und
beim Wiedereinschalten ist das Bild sofort da. Der Server erfaehrt es
nur, damit bei den ANDEREN "Kamera aus" im Fenster steht. Ohne diese
Beschriftung sind ein abgeschaltetes und ein kaputtes Bild dasselbe
schwarze Rechteck -- und dann fragt jemand im Chat, ob die Technik
hakt.
Erst das Geraet, dann die Ansage. Andersherum stuende bei allen
"Kamera aus", waehrend noch ein Bild fliesst; man glaubte sich
unsichtbar. Geht die Ansage nicht durch, wird das Geraet
zurueckgesetzt -- ein Knopf, der halb wirkt, ist schlimmer als einer,
der gar nicht wirkt.
Das Mikro laeuft denselben Weg. Erst wollte ich es rein oertlich
lassen; das waere dieselbe stille Falle gewesen: Wer sich selbst
stummschaltet und trotzdem redet, saehe bei allen anderen ein ganz
normales Fenster.
CHAT
Es gab Moderation -- Beitraege wegnehmen -- aber keine Steuerung des
Chats selbst. Einen Beitrag zu loeschen, nachdem er stand, ist etwas
anderes, als ihn gar nicht erst zuzulassen.
Drei Stufen: OFFEN, TEAM (wer moderiert, darf -- wer aufraeumen soll,
muss dabei reden koennen) und ZU (nur die zwei, die fuehren). Zwei
Stufen waeren zu wenig: "zu" ist in einer Sendung fast immer zu viel,
dann sitzen alle vor einem stummen Fenster. Die mittlere ist die, die
man wirklich braucht.
Die Schalter stehen im Kopf der Chatschiene, nicht im Regiepult: Man
moderiert, wo man liest. Ein Umweg ueber ein Register waere in dem
Moment, in dem es laut wird, genau ein Umweg zu viel.
EIN GESPERRTES FELD SAGT, WARUM. "Gerade schreibt nur das Team" statt
eines Feldes, das sich nicht beschreiben laesst und schweigt --
sonst schreibt jemand in den Hauschat, dass die Reaction hakt. Und
die Stufe gilt AM SERVER: Ein ausgegrautes Feld haelt niemanden auf,
der die Schnittstelle kennt. Beide Antworten kommen aus derselben
Funktion; zwei Rechnungen waeren zwei Gelegenheiten, dass ein Feld da
ist und mit 403 antwortet.
EIN FUND NEBENBEI: "MEIN MIKRO" WAR EIN PLACEBO
Gemessen: `staende.mikro` wird nirgends gelesen. Der Schieber liess
sich bewegen, die Zahl daneben aenderte sich -- und es passierte
nichts. Das ist schlimmer als ein fehlender Regler: Man glaubt, man
haette leiser gestellt.
Technisch ist das auch richtig. Die eigene Lautstaerke laesst sich
nicht am Regler aendern; man muesste den Ton umrechnen und die Spur
in jeder Verbindung austauschen. Was man beim eigenen Mikro braucht,
ist AN oder AUS -- und ein Pegel, der zeigt, dass es ankommt. Genau
das steht jetzt dort, als vierte Spalte im Pult, mit demselben
Zustand wie die Senderleiste. Die drei anderen Regler bleiben Regler:
Sie steuern, was ICH hoere, und das geht am Empfaenger.
UND EINER IN MEINER EIGENEN ARBEIT
`kasten.append(el("div","pegel")).append(el("i"))` -- `Node.append()`
gibt `undefined` zurueck, nicht das angehaengte Element. Ein
TypeError beim Aufbau des Pults, den `node --check` nicht sieht.
Beim Verkuerzen nicht nachgesehen, was die Methode zurueckgibt.
Dazu: Die Pegel-Takte liefen in `pultAufbauen()`. Das Pult hat nur,
wer die Sendung fuehrt -- ein Gast haette seinen Pegel nie gesehen,
und genau er braucht ihn am dringendsten.
GEMESSEN
mess-reaktion Der Host schaltet ab, und bei der Zuschauerin steht
"Kamera aus" bei DogFather, Bild verdeckt.
Stufe "Team": Feld gesperrt mit Grund, und der
Server lehnt denselben Versuch mit 403 ab.
pruef-reaktion 154 Punkte, 0 Fehler (vorher 132), neuer Abschnitt
13 mit 22 Punkten und Gegenprobe (es geht auch
wieder auf -- eine Sperre, die man nicht loesen
kann, ist keine Stufe, sondern ein Ende)
dazu gruen handy 180, css-klassen 33, struktur 35,
tippziele 11, aufbewahrung 45, meldungen 8
Der neue Abschnitt baut sich seine Buehne selbst. Abschnitt 9 beendet
die Sendung; sich auf den Stand eines frueheren Abschnitts zu
verlassen ist die Kopplung, die spaeter jemand aus Versehen
zerreisst.
SCHEMA: drei Spalten (reaktion_dabei.kamera_aus, .mikro_aus,
reaktion.chat_modus), alle per ALTER TABLE. Auf einer Kopie der
echten Datenbank durchgespielt: 20 Personen, keine Tabelle verliert
eine Spalte.
571 lines
25 KiB
JavaScript
571 lines
25 KiB
JavaScript
/* =====================================================================
|
|
WIE DIE REACTION AUSSIEHT (28.09.2026)
|
|
|
|
Die Pruefung nebenan misst, was die Schnittstelle antwortet. Das
|
|
sagt nichts darueber, ob ein Mensch damit einen Abend verbringen
|
|
moechte. Dieses Werkzeug oeffnet die Seite in einem echten Browser,
|
|
schaltet sie durch alle drei Zustaende und legt Bilder ab.
|
|
|
|
Gemessen wird dabei auch, was man sonst uebersieht:
|
|
- laeuft etwas in die Konsole?
|
|
- ragt etwas seitlich hinaus?
|
|
- ist der Saal so hoch wie das Fenster, oder muss man scrollen?
|
|
|
|
NIE DIE ECHTE DATENBANK. Eigener Ordner, eigener Port, eigenes
|
|
Zertifikat -- und am Ende ist alles weg.
|
|
|
|
Aufrufen mit: node server/mess-reaktion.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 { execFileSync } from "node:child_process";
|
|
import { createServer as httpsServer } from "node:https";
|
|
|
|
const HIER = dirname(fileURLToPath(import.meta.url));
|
|
const ordner = mkdtempSync(join(tmpdir(), "mess-reakt-"));
|
|
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
|
|
|
const PORT = 5483;
|
|
const HTTPS_PORT = PORT + 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";
|
|
|
|
for (const art of ["uncaughtException", "unhandledRejection"]) {
|
|
process.on(art, (f) => {
|
|
console.error(`\nABBRUCH (${art}): ${f?.stack || f}`);
|
|
process.exit(8);
|
|
});
|
|
}
|
|
|
|
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");
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
|
|
const CREW = "crew.dogfather-universe.com";
|
|
|
|
/* ---------- Leute ------------------------------------------------------- */
|
|
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);
|
|
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
|
}
|
|
const CODES = { admin: "CODE-M00-000001", gast: "CODE-M01-000001", gast2: "CODE-M02-000001" };
|
|
const IDS = {
|
|
admin: anlegen("DogFather", "admin", CODES.admin),
|
|
gast: anlegen("Nachtfalter", "gast", CODES.gast),
|
|
gast2: 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(rolle) {
|
|
const a = await roh("/workspace/api/anmelden", {
|
|
method: "POST",
|
|
rumpf: { rolle: rolle === "gast2" ? "gast" : rolle, code: CODES[rolle], 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"),
|
|
};
|
|
|
|
/* ---------- Die Buehne fuellen ------------------------------------------ */
|
|
await roh("/workspace/api/reaktion", {
|
|
method: "PUT", keks: k.admin,
|
|
rumpf: {
|
|
titel: "Wir schauen den neuen Trailer",
|
|
video: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
beginnt_am: new Date(Date.now() + 7 * 60_000).toISOString(),
|
|
},
|
|
});
|
|
|
|
/* ZWEI STUECKE IN DIE WARTESCHLANGE. Eine leere Liste sieht auf
|
|
einem Bildschirmfoto genauso aus wie eine kaputte -- beides ist
|
|
ein Kasten ohne Zeilen. Zwei echte Eintraege zeigen, ob Titel,
|
|
Vorschaubild und die vier Knoepfe je Zeile wirklich ankommen. */
|
|
for (const adresse of ["https://youtu.be/9bZkp7q19f0",
|
|
"https://www.youtube.com/watch?v=kJQP7kiw5Fk"]) {
|
|
await roh("/workspace/api/reaktion/liste", {
|
|
method: "POST", keks: k.admin, rumpf: { video: adresse },
|
|
});
|
|
}
|
|
|
|
const REDEN = [
|
|
["gast", "Bin schon daaa 🐺"],
|
|
["gast2", "Ich auch! Hab extra Popcorn geholt"],
|
|
["gast", "Wie lange noch?"],
|
|
["admin", "Zwei Minuten, ich richte noch die Kamera"],
|
|
["gast2", "Kein Stress, wir sitzen bequem"],
|
|
["gast", "Letztes Mal war so gut, ich hab geheult"],
|
|
];
|
|
|
|
/* ---------- Der Vorbau -------------------------------------------------- */
|
|
const schluesselDatei = join(ordner, "p.key");
|
|
const zertDatei = join(ordner, "p.crt");
|
|
execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes",
|
|
"-keyout", schluesselDatei, "-out", zertDatei, "-days", "2",
|
|
"-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" });
|
|
|
|
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}`;
|
|
|
|
const pw = await import(
|
|
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
|
const browser = await pw.chromium.launch({
|
|
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors",
|
|
/* Kamera und Mikrofon ohne Rueckfrage -- und ein bewegtes
|
|
Testbild statt eines schwarzen Rechtecks. Ohne das sieht jede
|
|
Aufnahme aus, als waere die Kamera kaputt. */
|
|
"--use-fake-ui-for-media-stream", "--use-fake-device-for-media-stream"],
|
|
});
|
|
|
|
/* JEDE GEOEFFNETE SEITE HAELT ZWEI STEHENDE VERBINDUNGEN (der
|
|
Ereignisstrom der Kopfleiste und der dieser Seite). Ein Browser
|
|
laesst je Adresse nur wenige davon zu -- wer seine Seiten nicht
|
|
schliesst, nimmt den spaeteren den Platz weg.
|
|
|
|
GEMESSEN: Die Seiten aus den ersten beiden Abschnitten blieben
|
|
offen, und danach kam Lennys Strom gar nicht mehr zustande. Die
|
|
Signale gingen ins Leere, und die Kamerafenster blieben schwarz --
|
|
ein Messfehler, der wie ein Programmfehler aussah. */
|
|
async function alsWer(rolle, breite, hoehe) {
|
|
const kontext = await browser.newContext({
|
|
viewport: { width: breite, height: hoehe },
|
|
ignoreHTTPSErrors: true,
|
|
permissions: ["camera", "microphone"],
|
|
});
|
|
await kontext.addCookies((k[rolle] || "").split("; ").filter(Boolean).map((z) => {
|
|
const [n, ...r] = z.split("=");
|
|
return { name: n, value: r.join("="), domain: CREW, path: "/" };
|
|
}));
|
|
const seite = await kontext.newPage();
|
|
const konsole = [];
|
|
const anfragen = [];
|
|
seite.on("request", (r) => { if (/\/api\//.test(r.url())) anfragen.push(r.url().replace(/^https?:\/\/[^/]+/, "")); });
|
|
seite.on("pageerror", (f) => konsole.push(String(f?.message || f)));
|
|
seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); });
|
|
seite.on("response", (a) => {
|
|
if (a.status() >= 400) konsole.push(`${a.status()} ${a.url().replace(/^https?:\/\/[^/]+/, "")}`);
|
|
});
|
|
return { kontext, seite, konsole, anfragen };
|
|
}
|
|
|
|
async function bild(seite, name) {
|
|
await seite.screenshot({ path: join(HIER, `mess-reaktion-${name}.png`) });
|
|
console.log(` Bild: server/mess-reaktion-${name}.png`);
|
|
}
|
|
|
|
async function vermessen(seite, wo) {
|
|
const m = await seite.evaluate(() => ({
|
|
ueber: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
|
|
hoch: Math.max(0, document.documentElement.scrollHeight - document.documentElement.clientHeight),
|
|
stand: document.getElementById("saal")?.dataset.stand || "?",
|
|
schild: document.getElementById("schild-wort")?.textContent || "",
|
|
}));
|
|
console.log(` ${wo}: Stand „${m.stand}", Schild „${m.schild}", `
|
|
+ `seitlich ${m.ueber} px, senkrecht ${m.hoch} px`);
|
|
return m;
|
|
}
|
|
|
|
console.log("\n=== ZU ===");
|
|
{
|
|
const { kontext, seite, konsole } = await alsWer("gast", 1440, 900);
|
|
await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
|
|
await seite.waitForTimeout(900);
|
|
await vermessen(seite, "Rechner");
|
|
await bild(seite, "1-zu-rechner");
|
|
if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | "));
|
|
await kontext.close();
|
|
}
|
|
|
|
console.log("\n=== VORBEREITUNG ===");
|
|
await roh("/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "vorbereitung" } });
|
|
for (const [wer, text] of REDEN) {
|
|
await roh("/workspace/api/reaktion/chat", { method: "POST", keks: k[wer], rumpf: { text } });
|
|
}
|
|
{
|
|
const { kontext, seite, konsole } = await alsWer("gast", 1440, 900);
|
|
await seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
|
|
await seite.waitForTimeout(1400);
|
|
await vermessen(seite, "Rechner");
|
|
await bild(seite, "2-warte-rechner");
|
|
if (konsole.length) console.log(" KONSOLE:", konsole.slice(0, 3).join(" | "));
|
|
await kontext.close();
|
|
}
|
|
|
|
console.log("\n=== LIVE ===");
|
|
await roh("/workspace/api/reaktion/stand", { method: "POST", keks: k.admin, rumpf: { stand: "live" } });
|
|
await roh("/workspace/api/reaktion/gast", { method: "POST", keks: k.admin, rumpf: { person_id: IDS.gast2 } });
|
|
|
|
/* ==== BEIDE SEITEN GLEICHZEITIG ====================================
|
|
Der Host sendet, die Zuschauerin empfaengt -- das geht nur, wenn
|
|
beide zur selben Zeit offen sind. */
|
|
const host = await alsWer("admin", 1440, 900);
|
|
await host.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
|
|
await host.seite.waitForTimeout(2500);
|
|
|
|
const gast2 = await alsWer("gast2", 1280, 800);
|
|
await gast2.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
|
|
await gast2.seite.waitForTimeout(2500);
|
|
|
|
const zuschauer = await alsWer("gast", 1440, 900);
|
|
await zuschauer.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
|
|
|
|
/* Verbindungen brauchen ein paar Sekunden: Adressen holen, Angebot,
|
|
Antwort, Kandidaten. Acht Sekunden sind grosszuegig -- es geht
|
|
darum, ob es UEBERHAUPT ankommt, nicht wie schnell. */
|
|
await zuschauer.seite.waitForTimeout(8000);
|
|
|
|
async function bildBreiten(seite) {
|
|
return seite.evaluate(() => {
|
|
const raus = {};
|
|
for (const k2 of document.querySelectorAll("#kameras .kamera")) {
|
|
const v = k2.querySelector("video");
|
|
raus[k2.querySelector(".kamera__name")?.textContent || k2.dataset.wer] = {
|
|
breite: v ? v.videoWidth : -1,
|
|
strom: !!(v && v.srcObject),
|
|
laeuft: !!(v && !v.paused),
|
|
stumm: !!(v && v.muted),
|
|
};
|
|
}
|
|
return raus;
|
|
});
|
|
}
|
|
|
|
console.log("\n --- Fliesst ein Bild? ---");
|
|
for (const [wer, w] of [["Host", host.seite], ["Gast", gast2.seite], ["Zuschauerin", zuschauer.seite]]) {
|
|
const z = await w.evaluate(() => ({
|
|
url: location.pathname,
|
|
saal: !!document.getElementById("saal"),
|
|
stand: document.getElementById("saal")?.dataset.stand,
|
|
kameras: document.querySelectorAll("#kameras .kamera").length,
|
|
})).catch((f) => ({ url: "FEHLER " + f.message }));
|
|
console.log(` ${wer.padEnd(12)} ${z.url} saal=${z.saal} stand=${z.stand} kameras=${z.kameras}`);
|
|
const q = { Host: host, Gast: gast2, Zuschauerin: zuschauer }[wer];
|
|
console.log(" Strom-Anfragen: "
|
|
+ q.anfragen.filter((x) => x.includes("strom")).length + " | Signale: " + q.anfragen.filter((x) => x.includes("signal")).length);
|
|
}
|
|
const beiZuschauer = await bildBreiten(zuschauer.seite);
|
|
for (const [name, m] of Object.entries(beiZuschauer)) {
|
|
const gut = m.breite > 0;
|
|
console.log(` Zuschauerin sieht ${name.padEnd(12)} `
|
|
+ `${gut ? "BILD " + m.breite + " px" : (m.strom ? "Strom da, aber 0 px" : "NICHTS")}`
|
|
+ ` ${m.laeuft ? "laeuft" : "STEHT"} ${m.stumm ? "stumm" : "mit Ton"}`);
|
|
}
|
|
/* AUCH DER HOST MUSS SEINEN GAST SEHEN. Wenn nur die Zuschauer
|
|
etwas sehen, redet der Host gegen eine schwarze Kachel -- und
|
|
merkt nicht, wenn beim Gast die Kamera aus ist. */
|
|
/* LAEUFT DAS VIDEO UEBERHAUPT? Dieselbe Falle wie bei den Kameras:
|
|
Etwas, das Ton macht, darf nicht von allein losgehen. */
|
|
let videoLaeuft = 0;
|
|
for (const [wer, w] of [["Host", host.seite], ["Zuschauerin", zuschauer.seite]]) {
|
|
const v = await w.evaluate(() => ({
|
|
stand: document.getElementById("video-platz")?.dataset.spieler || "(nichts)",
|
|
rahmen: !!document.querySelector("#video-platz iframe"),
|
|
}));
|
|
console.log(` Video bei ${wer.padEnd(12)} Rahmen=${v.rahmen} Zustand=${v.stand}`);
|
|
if (v.stand === "laeuft" || v.stand === "puffert") videoLaeuft++;
|
|
}
|
|
const beimHost = await bildBreiten(host.seite);
|
|
for (const [name, m] of Object.entries(beimHost)) {
|
|
console.log(` Host sieht ${name.padEnd(12)} `
|
|
+ `${m.breite > 0 ? "BILD " + m.breite + " px" : "NICHTS"}`
|
|
+ ` ${m.laeuft ? "laeuft" : "STEHT"}`);
|
|
}
|
|
const zaehlt = Object.values(beiZuschauer).filter((m) => m.breite > 0).length;
|
|
console.log(` -> ${zaehlt} von ${Object.keys(beiZuschauer).length} Kameras kommen an.`);
|
|
/* DIESE MESSUNG HAT EINEN RUECKGABEWERT -- und zwar wegen dieser
|
|
einen Zahl. Alles andere kann gruen sein: Die Schnittstelle
|
|
antwortet, die Seite sieht gut aus, die Verbindung steht. Wenn
|
|
hier 0 steht, sitzt trotzdem jeder vor einem schwarzen Rechteck.
|
|
|
|
Gemessen wurde genau das zweimal: einmal, weil das Angebot
|
|
hinausging, bevor der Empfaenger zuhoeren konnte -- einmal, weil
|
|
ein Video mit Ton nicht von allein startet. Beide Male sahen die
|
|
Bildschirmfotos plausibel aus. */
|
|
let ausgang = 0;
|
|
/* LAEUFT DAS VIDEO? Ein Zuschauer hat keine Bedienung -- startet es
|
|
nicht von selbst, sitzt er eine Stunde vor einem Standbild und
|
|
kann nichts dagegen tun. Gemessen war genau das der Fall. */
|
|
if (videoLaeuft < 2) {
|
|
console.log(` ACHTUNG: Das Video laeuft nur bei ${videoLaeuft} von 2.`);
|
|
ausgang = 1;
|
|
}
|
|
const beimHostAn = Object.values(beimHost).filter((m) => m.breite > 0).length;
|
|
if (beimHostAn < Object.keys(beimHost).length) {
|
|
console.log(" ACHTUNG: Der Host sieht nicht alle Kameras.");
|
|
ausgang = 1;
|
|
}
|
|
if (!zaehlt) {
|
|
console.log(" ACHTUNG: Es kommt kein einziges Bild an.");
|
|
ausgang = 1;
|
|
}
|
|
const stehen = [...Object.values(beiZuschauer), ...Object.values(beimHost)]
|
|
.filter((m) => m.breite > 0 && !m.laeuft).length;
|
|
if (stehen) {
|
|
console.log(` ACHTUNG: ${stehen} Bild(er) kommen an, stehen aber still `
|
|
+ "-- ein Video mit Ton startet nicht von allein.");
|
|
ausgang = 1;
|
|
}
|
|
|
|
/* ---- Die Regie ---------------------------------------------------
|
|
NICHT UMSCHALTEN, SONDERN HERSTELLEN. Ein Klick auf „Griff" heisst
|
|
„das Gegenteil von dem, was gerade ist" -- und setzt damit voraus,
|
|
dass die Messung den Zustand vorher kennt. Genau daran ist sie
|
|
einmal gescheitert: Seit das Pult aufgeklappt startet, hat der
|
|
Klick es ZUgemacht, und auf dem Bild war von der Regie nichts zu
|
|
sehen. */
|
|
console.log("");
|
|
console.log(" --- Die Regie ---");
|
|
await host.seite.evaluate(() => {
|
|
const p = document.getElementById("pult");
|
|
if (p && p.dataset.auf !== "ja") document.getElementById("pult-griff")?.click();
|
|
});
|
|
await host.seite.waitForTimeout(900);
|
|
|
|
const regie = await host.seite.evaluate(() => {
|
|
const t = (id) => document.getElementById(id)?.textContent?.trim() || "";
|
|
const sicht = (w) => {
|
|
const e = document.querySelector(w);
|
|
if (!e) return false;
|
|
const r = e.getBoundingClientRect();
|
|
return r.width > 0 && r.height > 0;
|
|
};
|
|
return {
|
|
auf: document.getElementById("pult")?.dataset.auf,
|
|
lampe: t("pult-wort"),
|
|
uhr: t("pult-zeit"),
|
|
dabei: t("z-dabei"),
|
|
sicht_zahl: t("z-sicht"),
|
|
last: t("z-last"),
|
|
spur: t("spur-titel"),
|
|
zeit: t("v-zeit"),
|
|
reiter: document.querySelectorAll("#reiter .reiter__knopf").length,
|
|
tafel_sichtbar: [...document.querySelectorAll(".pult-tafel")].filter((e) => !e.hidden).length,
|
|
regler: document.querySelectorAll("#regler .regler").length,
|
|
pegel: document.querySelectorAll("#regler .pegel").length,
|
|
knoepfe_sichtbar: ["p-vorbereiten", "p-starten", "p-beenden"]
|
|
.filter((id) => sicht("#" + id)).length,
|
|
/* WOHER KOMMT EIN UEBERLAUF? Die blosse Zahl „197 Pixel zum
|
|
Scrollen" schickt einen ins Raten. Die Hoehen daneben zeigen
|
|
in einer Zeile, welcher Kasten zu gross ist. */
|
|
hoehen: (() => {
|
|
const h = (w) => {
|
|
const e = document.querySelector(w);
|
|
return e ? Math.round(e.getBoundingClientRect().height) : -1;
|
|
};
|
|
return `fenster ${window.innerHeight} · saal ${h("#saal")}`
|
|
+ ` · kino ${h("#teil-live")} · pult ${h("#pult")}`
|
|
+ ` (leiste ${h(".pult__leiste")} + innen ${h("#pult-innen")})`
|
|
+ ` · tafeln sichtbar ${[...document.querySelectorAll(".pult-tafel")]
|
|
.filter((e) => e.getBoundingClientRect().height > 0).length}`
|
|
+ ` · innen rollt ${document.getElementById("pult-innen").scrollHeight}`;
|
|
})(),
|
|
kinder: [...document.getElementById("pult-innen").children].map((e) => {
|
|
const r = e.getBoundingClientRect();
|
|
const c = getComputedStyle(e);
|
|
return `${e.className || e.tagName}:${Math.round(r.top)}+${Math.round(r.height)}`
|
|
+ `/${c.display}/${c.position}`;
|
|
}).join(" "),
|
|
};
|
|
});
|
|
console.log(" " + regie.hoehen);
|
|
console.log(" " + regie.kinder);
|
|
console.log(` Lampe „${regie.lampe}" Uhr ${regie.uhr} `
|
|
+ `Sehen zu ${regie.dabei} mit Bild ${regie.sicht_zahl} Upload ${regie.last}`);
|
|
console.log(` Spur: „${regie.spur}" ${regie.zeit}`);
|
|
console.log(` ${regie.reiter} Register, ${regie.tafel_sichtbar} Tafel offen, `
|
|
+ `${regie.regler} Regler mit ${regie.pegel} Pegeln, `
|
|
+ `${regie.knoepfe_sichtbar}/3 grosse Knoepfe sichtbar`);
|
|
|
|
/* JEDE DIESER ZAHLEN KANN STILL FALSCH SEIN -- und genau dann sieht
|
|
ein Bildschirmfoto trotzdem gut aus. Deshalb werden sie geprueft
|
|
und nicht nur ausgegeben. */
|
|
if (regie.auf !== "ja") {
|
|
console.log(" ACHTUNG: Die Regie liess sich nicht aufklappen.");
|
|
ausgang = 1;
|
|
}
|
|
if (!/^\d+:\d\d/.test(regie.uhr) || regie.uhr === "0:00") {
|
|
console.log(` ACHTUNG: Die Laufzeit steht still (${regie.uhr}).`);
|
|
ausgang = 1;
|
|
}
|
|
if (!/kbit\/s|Mbit\/s/.test(regie.last)) {
|
|
console.log(` ACHTUNG: Der Upload wird nicht gemessen (${regie.last}).`);
|
|
ausgang = 1;
|
|
}
|
|
if (regie.reiter !== 5 || regie.tafel_sichtbar !== 1) {
|
|
console.log(` ACHTUNG: Die Register stimmen nicht (${regie.reiter} Reiter, `
|
|
+ `${regie.tafel_sichtbar} offen).`);
|
|
ausgang = 1;
|
|
}
|
|
if (regie.regler !== 4 || regie.pegel !== 4 || regie.knoepfe_sichtbar !== 3) {
|
|
console.log(" ACHTUNG: Regler, Pegel oder die grossen Knoepfe fehlen.");
|
|
ausgang = 1;
|
|
}
|
|
|
|
/* ---- Kamera aus und Chat-Stufe ------------------------------------
|
|
Beides ist erst gebaut, wenn es beim ZUSCHAUER ankommt. */
|
|
console.log("");
|
|
console.log(" --- Kamera aus, Chat zu ---");
|
|
|
|
await host.seite.click("#s-kamera").catch(() => {});
|
|
await host.seite.waitForTimeout(1200);
|
|
const nachKamera = await zuschauer.seite.evaluate(() => {
|
|
const f = document.querySelector('#kameras .kamera[data-kamera="aus"]');
|
|
return {
|
|
schild: f ? f.querySelector(".kamera__aus")?.textContent?.trim() : null,
|
|
wessen: f ? f.querySelector(".kamera__name")?.textContent?.trim() : null,
|
|
videoVersteckt: f ? getComputedStyle(f.querySelector("video")).opacity === "0" : false,
|
|
};
|
|
});
|
|
console.log(` Zuschauerin sieht: „${nachKamera.schild || "(nichts)"}" `
|
|
+ `bei ${nachKamera.wessen || "(niemandem)"}, Bild verdeckt=${nachKamera.videoVersteckt}`);
|
|
if (nachKamera.schild !== "Kamera aus" || !nachKamera.videoVersteckt) {
|
|
console.log(" ACHTUNG: Das abgeschaltete Bild kommt nicht als Schild an.");
|
|
ausgang = 1;
|
|
}
|
|
await host.seite.click("#s-kamera").catch(() => {});
|
|
await host.seite.waitForTimeout(900);
|
|
|
|
await host.seite.click('#chat-stufen .stufe[data-modus="team"]').catch(() => {});
|
|
await host.seite.waitForTimeout(1200);
|
|
const nachChat = await zuschauer.seite.evaluate(() => {
|
|
const f = document.getElementById("chat-feld");
|
|
return { gesperrt: !!f?.disabled, grund: f?.placeholder || "",
|
|
stufe: document.getElementById("schiene")?.dataset.chat };
|
|
});
|
|
console.log(` Zuschauerin: Feld gesperrt=${nachChat.gesperrt} `
|
|
+ `Grund „${nachChat.grund}" Stufe=${nachChat.stufe}`);
|
|
/* GEGENPROBE AM SERVER. Ein ausgegrautes Feld haelt niemanden auf,
|
|
der die Schnittstelle kennt -- also wird auch dort nachgefragt. */
|
|
const trotzdem = await roh("/workspace/api/reaktion/chat", {
|
|
method: "POST", keks: k.gast, rumpf: { text: "geht das trotzdem?" },
|
|
});
|
|
console.log(` ... und der Server lehnt ab: ${trotzdem.code}/${trotzdem.j?.fehler}`);
|
|
if (!nachChat.gesperrt || !nachChat.grund.includes("Team") || trotzdem.code !== 403) {
|
|
console.log(" ACHTUNG: Die Chat-Stufe kommt nicht an.");
|
|
ausgang = 1;
|
|
}
|
|
await host.seite.click('#chat-stufen .stufe[data-modus="offen"]').catch(() => {});
|
|
await host.seite.waitForTimeout(900);
|
|
|
|
await vermessen(host.seite, "Host am Rechner");
|
|
await bild(host.seite, "3-live-host");
|
|
|
|
/* Und einmal die Warteschlange -- das ist der Teil, den Filipe
|
|
vorher benutzt, nicht waehrend der Sendung. */
|
|
await host.seite.click('#reiter .reiter__knopf[data-tafel="liste"]').catch(() => {});
|
|
await host.seite.waitForTimeout(600);
|
|
const warte = await host.seite.evaluate(() => ({
|
|
zeilen: document.querySelectorAll("#warteliste .warte-zeile").length,
|
|
bilder: [...document.querySelectorAll(".warte-zeile__bild")]
|
|
.filter((b) => b.naturalWidth > 0).length,
|
|
titel: [...document.querySelectorAll(".warte-zeile__titel")]
|
|
.map((e) => e.textContent.trim()).join(" | "),
|
|
naechstes: document.getElementById("v-naechstes")?.disabled,
|
|
}));
|
|
console.log(` Warteschlange: ${warte.zeilen} Zeilen, `
|
|
+ `${warte.bilder} Vorschaubilder geladen, „Naechstes" `
|
|
+ `${warte.naechstes ? "grau" : "nutzbar"}`);
|
|
console.log(` ${warte.titel}`);
|
|
if (warte.zeilen !== 2 || warte.naechstes) {
|
|
console.log(" ACHTUNG: Die Warteschlange kommt nicht an.");
|
|
ausgang = 1;
|
|
}
|
|
await bild(host.seite, "7-live-regie-liste");
|
|
await host.seite.click('#reiter .reiter__knopf[data-tafel="ton"]').catch(() => {});
|
|
await host.seite.waitForTimeout(500);
|
|
await bild(host.seite, "8-live-regie-ton");
|
|
if (host.konsole.length) console.log(" KONSOLE:", host.konsole.slice(0, 4).join(" | "));
|
|
|
|
await vermessen(zuschauer.seite, "Zuschauerin am Rechner");
|
|
await bild(zuschauer.seite, "4-live-zuschauer");
|
|
if (zuschauer.konsole.length) console.log(" KONSOLE:", zuschauer.konsole.slice(0, 4).join(" | "));
|
|
|
|
await host.kontext.close();
|
|
await gast2.kontext.close();
|
|
await zuschauer.kontext.close();
|
|
|
|
{
|
|
const handy = await alsWer("gast2", 390, 844);
|
|
await handy.seite.goto(`${BASIS}/workspace/reaktion.html`, { waitUntil: "networkidle" });
|
|
await handy.seite.waitForTimeout(4000);
|
|
await vermessen(handy.seite, "Handy");
|
|
await bild(handy.seite, "5-live-handy");
|
|
|
|
await handy.seite.waitForTimeout(500);
|
|
await bild(handy.seite, "6-live-handy-chat");
|
|
const hm = await handy.seite.evaluate(() => {
|
|
const g = (w) => { const e = document.querySelector(w); if (!e) return "(fehlt)";
|
|
const r = e.getBoundingClientRect(); const c = getComputedStyle(e);
|
|
return `${Math.round(r.width)}x${Math.round(r.height)} display=${c.display}`; };
|
|
return { saal: g("#saal"), kino: g("#teil-live"), leinwand: g(".leinwand"),
|
|
schiene: g(".schiene"), schieneWert: document.getElementById("teil-live")?.dataset.schiene };
|
|
});
|
|
console.log(" Handy-Masse:", JSON.stringify(hm));
|
|
if (handy.konsole.length) console.log(" KONSOLE:", handy.konsole.slice(0, 4).join(" | "));
|
|
}
|
|
|
|
await browser.close();
|
|
await new Promise((r) => vorbau.close(r));
|
|
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
|
console.log("\nFertig.");
|
|
process.exit(ausgang);
|