From e93c4067f97746e98e848e823cc642da3e0ba1fb Mon Sep 17 00:00:00 2001 From: Dogfather Date: Thu, 8 Oct 2026 21:58:37 +0200 Subject: [PATCH] Das Video haengt nicht mehr, wenn die Kamera laeuft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- server/mess-kameralast.mjs | 370 ++++++++++++++++++++++++++++++ workspace/anleitung.html | 28 +-- workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/reaktion.css | 49 +++- workspace/assets/js/reaktion.js | 142 +++++++++++- workspace/aufgaben.html | 38 +-- workspace/automation.html | 32 +-- workspace/befinden.html | 34 +-- workspace/bewerben.html | 38 +-- workspace/bewerbungen.html | 36 +-- workspace/buehne.html | 6 +- workspace/calls.html | 34 +-- workspace/chat.html | 42 ++-- workspace/content.html | 34 +-- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 +-- workspace/entwicklung.html | 36 +-- workspace/hilfe.html | 30 +-- workspace/index.html | 10 +- workspace/kalender.html | 34 +-- workspace/leistung.html | 32 +-- workspace/manager-ziele.html | 30 +-- workspace/material.html | 32 +-- workspace/notizen.html | 28 +-- workspace/personen.html | 32 +-- workspace/profil.html | 36 +-- workspace/reaktion-foyer.html | 30 +-- workspace/reaktion.html | 40 ++-- workspace/rechte.html | 34 +-- workspace/report.html | 36 +-- workspace/scouting.html | 32 +-- workspace/start.html | 38 +-- workspace/startcheck.html | 34 +-- workspace/steckbrief.html | 36 +-- workspace/support.html | 28 +-- workspace/tafel.html | 6 +- workspace/talente.html | 36 +-- workspace/team.html | 32 +-- workspace/teamlage.html | 34 +-- workspace/teilen.html | 30 +-- workspace/treff-moderation.html | 32 +-- workspace/treff-regeln.html | 32 +-- workspace/uebersicht.html | 32 +-- workspace/unsere-seiten.html | 30 +-- workspace/unterstuetzen.html | 32 +-- workspace/werdegang.html | 32 +-- workspace/willkommen.html | 2 +- workspace/wissen.html | 34 +-- 50 files changed, 1229 insertions(+), 698 deletions(-) create mode 100644 server/mess-kameralast.mjs 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. --> - + - - - - - - + + + + + + diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index 7aebcdeb..e3c14662 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +