diff --git a/.gitignore b/.gitignore index 5e56979d..f56f2e20 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ tools/.nachpruef* tools/.handy-detail.txt tools/.rest* + +videos/ +tools/.vid/ diff --git a/server/pruef-kachelraster.mjs b/server/pruef-kachelraster.mjs index 598aff98..44f60d6b 100644 --- a/server/pruef-kachelraster.mjs +++ b/server/pruef-kachelraster.mjs @@ -372,6 +372,80 @@ try { ok(ende.zellen === 8 && ende.minimum === 3, "auch die Sicht eines Zuschauers (7 Kacheln) geht auf drei Reihen auf"); } + /* ===================================================================== + JEDE KACHEL IST AUCH BREIT GENUG (27.09.2026) + ===================================================================== + + Gefunden beim Filmen der Startseite fuer TikTok, also mit dem + Auge und nicht mit einer Pruefung. Auf einem 320er Geraet waren + „Rudel-Chat" und „Anschlagbrett" DREI PIXEL breit -- ein + senkrechter Strich mit abgeschnittenem Text. + + Die Ursache stand in start.css: Unter 380 px wird das Raster + einspaltig, die Willkommenskachel spannte aber weiter ueber zwei + Spalten. Ein Gitter mit einer erklaerten Spalte und einem Kind, + das zwei braucht, erfindet die zweite -- und teilt den Platz + 3 zu 281. + + WARUM ES KEINE PRUEFUNG GEMERKT HAT, und das ist der eigentliche + Befund: pruef-handy misst Ueberhang und Beruehrziele. Eine 3 px + breite Kachel ragt nicht hinaus, und ihr Link ist 142 px HOCH -- + die Mindestgroesse fuer den Finger war also erfuellt. Beide + Pruefungen waren gruen, und die Kachel war unbenutzbar. + + Gemessen wird deshalb ab jetzt die BREITE, und zwar bei den drei + Geraetebreiten, die das Haus ohnehin fuehrt. Die Grenze ist + abgeleitet und nicht gesetzt: Eine Kachel muss mindestens ein + Drittel der Inhaltsbreite haben -- schmaler waere sie auch bei + drei Spalten nicht. + ===================================================================== */ + + /* Eigene Anmeldung: `messen()` haelt seinen Keks in seinem eigenen + Gueltigkeitsbereich, und dieser Abschnitt steht daneben, nicht + darin. Genommen wird der Modi -- er ist die Rolle in dieser + Pruefung, die eine Kachel mit `gross: true` hat, und nur an der + kann der Fehler ueberhaupt auftreten. */ + const keksBreite = await anmelden("creator", "CODE-MODI-0001"); + + for (const breite of [320, 360, 390]) { + const k2 = await browser.newContext({ + viewport: { width: breite, height: 900 }, reducedMotion: "reduce", + }); + await k2.addCookies(keksBreite.split("; ").map((k) => { + const [name, ...rest] = k.split("="); + return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" }; + })); + const s2 = await k2.newPage(); + await s2.goto(`${BASIS}/workspace/start.html`, { waitUntil: "networkidle" }); + await s2.waitForSelector(".kacheln .kachel", { timeout: 15_000 }); + await s2.waitForTimeout(400); + const m2 = await s2.evaluate(() => { + const kacheln = [...document.querySelectorAll("li.kachel")]; + const raster = [...document.querySelectorAll(".kacheln")]; + const inhalt = document.querySelector(".kacheln")?.getBoundingClientRect().width || 0; + return { + anzahl: kacheln.length, + inhalt: Math.round(inhalt), + schmalste: Math.round(Math.min(...kacheln.map((x) => x.getBoundingClientRect().width))), + wer: kacheln.map((x) => ({ + n: (x.querySelector(".kachel__name")?.textContent || "?").slice(0, 18), + b: Math.round(x.getBoundingClientRect().width), + })).sort((a, b2) => a.b - b2.b).slice(0, 2), + /* Und eine erfundene Spalte faellt hier auf: mehr Spuren, als + die Regel erklaert. */ + spuren: raster.map((r) => getComputedStyle(r).gridTemplateColumns.split(" ").length), + }; + }); + const grenze = Math.floor(m2.inhalt / 3) - 8; + ok(m2.anzahl > 5, `${breite} px: ${m2.anzahl} Kacheln angesehen`); + ok(m2.schmalste >= grenze, + `${breite} px: schmalste Kachel ${m2.schmalste} px (mindestens ${grenze}) -- ` + + m2.wer.map((w) => `${w.n} ${w.b}px`).join(", ")); + ok(m2.spuren.every((z) => z <= (breite < 380 ? 1 : 2)), + `${breite} px: kein Raster hat mehr Spalten als erlaubt (${m2.spuren.join("/")})`); + await k2.close(); + } + } finally { await browser.close(); } diff --git a/server/pruef-notizen.mjs b/server/pruef-notizen.mjs index a3af8149..804ba578 100644 --- a/server/pruef-notizen.mjs +++ b/server/pruef-notizen.mjs @@ -34,9 +34,10 @@ Derselbe Grund wie bei pruef-handy-teamdogi: `dogfather-universe.com` steht in der fest eingebauten HSTS-Liste von Chromium, und ohne die - Team-Adresse gibt es diese Seite gar nicht — auf einer Prüfadresse - (127.0.0.1) ist `haus` weder „crew" noch „agentur", und der Block - antwortet zu Recht mit 404. + Team-Adresse sieht man die Trennung nicht: Auf einer Prüfadresse + (127.0.0.1) ist `haus` weder „crew" noch „agentur" — dort ist der + Block offen (so wie jedes andere Modul auch), und nur auf + `workspace.` wird er abgewiesen. Beides wird unten gemessen. Aufrufen mit: node server/pruef-notizen.mjs ===================================================================== */ @@ -188,6 +189,29 @@ for (const rolle of ["gast", "creator"]) { const s2 = await roh(CREW, "/workspace/notizen.html", { keks: kekse.admin }); ok(s2.code === 200, `auf der Team-Adresse schon (${s2.code})`); } + +/* ---- Und die Pruefadresse ist das ANDERE Ende derselben Regel -------- + (27.09.2026, nachgetragen, nachdem pruef-handy einen 404 gemeldet + hatte.) + + Auf 127.0.0.1 hat niemand ein Haus. Die erste Fassung der Schranke + fragte „ist das Haus crew?" und wies damit auch die Pruefadresse ab + -- die Seite lud, blieb leer und schrieb einen Fehler in die + Konsole. Gefragt wird jetzt „ist es das ANDERE Haus?", so wie es + hausWo() im ganzen Haus tut. + + Beide Enden gehoeren in dieselbe Pruefung: Wer nur das eine misst, + kann die Schranke jederzeit wieder zu scharf stellen, ohne dass + etwas rot wird. */ +{ + const keksProbe = await anmelden("admin", "127.0.0.1"); + ok(!!keksProbe, "auf der Pruefadresse meldet sich DogFather an"); + const a = await roh("127.0.0.1", "/workspace/api/notizblock", { keks: keksProbe }); + ok(a.code === 200 && Array.isArray(a.j?.notizen), + `und bekommt dort seinen Block (${a.code}) -- ohne Haus wird nicht gesperrt`); + const s3 = await roh("127.0.0.1", "/workspace/notizen.html", { keks: keksProbe }); + ok(s3.code === 200, `auch die Seite selbst (${s3.code})`); +} { const ohne = await roh(CREW, "/workspace/api/notizblock"); ok(ohne.code === 401, `ohne Anmeldung: 401 (${ohne.code})`); diff --git a/server/tiktok-videos.mjs b/server/tiktok-videos.mjs new file mode 100644 index 00000000..d4e81c59 --- /dev/null +++ b/server/tiktok-videos.mjs @@ -0,0 +1,690 @@ +/* ===================================================================== + VIER VIDEOS ÜBER DIE APP (27.09.2026) + + Diese Datei liegt in server/ und nicht in tools/, und das ist kein + Geschmack: `express` und die Module des Hauses werden von Node + RELATIV zur importierenden Datei aufgeloest. In tools/ gibt es kein + node_modules -- der erste Anlauf ist mit „Cannot find package + express" abgebrochen, bevor eine Zeile lief. + ===================================================================== + + Filipe: „ich brauch 4 richtig geile videos über die ganze app. die + ich und die modis posten können auf tiktok um leute auf die app zu + pushen. ohne link bitte. es darf kein link zu sehen sein aber alles + andere ja was nicht geheim an dogfather nur ist." + + --------------------------------------------------------------------- + WAS DIESES WERKZEUG TUT + + Es startet die echte App auf einer Wegwerf-Datenbank, füllt sie mit + erfundenen Leuten und Beiträgen, meldet sich als Community-Mitglied + an und FILMT die Bedienung — 1080 × 1920, scharf, ohne Browserrahmen + und ohne Adresszeile. Danach schneidet ffmpeg daraus fertige + MP4-Dateien. + + Aufnehmen: + node server/tiktok-videos.mjs alle vier + node server/tiktok-videos.mjs 2 nur das zweite + Für Video 2 (die Nachtruhe) muss der Chat schlafen: + TREFF_NACHT_AB=0 TREFF_NACHT_BIS=24 node server/tiktok-videos.mjs 2 + + Das Ergebnis liegt in videos/ und ist git-ignoriert. + + --------------------------------------------------------------------- + DREI REGELN, DIE HIER NICHT VERHANDELBAR SIND + + 1. KEIN LINK, NIRGENDS. Weder eine Adresszeile (die gibt es in einer + Playwright-Aufnahme ohnehin nicht) noch eine Adresse im Bild. + Deshalb wird `unsere-seiten.html` NICHT gefilmt — dort stehen die + Kanäle und der Shop mit ihren Adressen. Und deshalb enthält kein + erfundener Beitrag eine Adresse. `pruef-tiktok-videos` liest jedes + gefilmte Bild durch eine Texterkennung nach, statt es zu glauben. + + 2. NICHTS, WAS NUR DOGFATHER GEHÖRT. Gefilmt wird ausschließlich die + Sicht eines Community-Mitglieds (`gast`) — also genau das, was + jeder sieht, der einen Zugang hat. Der vertrauliche Meldeweg + taucht als KACHEL auf (dass es ihn gibt, ist die Botschaft), aber + nie von innen. + + 3. KEINE TESTDATEN IM LIVE-SYSTEM. Der Server läuft auf einer + Datenbank in einem mktemp-Ordner, auf einer eigenen Portnummer, + hinter einem eigenen Zertifikat. Die echte Seite wird nicht + angefasst — nicht einmal gelesen. + + --------------------------------------------------------------------- + WARUM ÜBER https UND EINEN VORBAU + + `dogfather-universe.com` steht in der fest eingebauten HSTS-Liste + von Chromium: Der Browser schaltet dort von sich aus auf https um. + Ohne die Team-Adresse gäbe es den Treff gar nicht (auf 127.0.0.1 ist + `haus` weder crew noch agentur). Also: eigenes Zertifikat, winziger + Vorbau, der alles unverändert weiterreicht. Dieselbe Bauart wie in + pruef-handy-teamdogi. + + --------------------------------------------------------------------- + WARUM DIE AUFNAHME WIRKLICH 1080 × 1920 IST + + Playwright skaliert ein Bild für das Video nur HERUNTER, nie herauf. + Eine Aufnahme mit 360 px Fensterbreite liegt deshalb als 360er Bild + in einer 1080er Leinwand — oben links, der Rest grau. Gemessen, beim + ersten Versuch. + + Der Ausweg ist `--force-device-scale-factor=3`: Das Fenster ist dann + 360 × 640 CSS-Pixel (die App nimmt ihr Handy-Layout) und gleichzeitig + 1080 × 1920 echte Bildpunkte. Genau die filmt Playwright. + ===================================================================== */ +import { mkdtempSync, rmSync, readFileSync, mkdirSync, existsSync, renameSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { request as httpAnfrage } from "node:http"; +import { execFileSync, spawnSync } from "node:child_process"; +import { createServer as httpsServer } from "node:https"; +import { createServer as netServer } from "node:net"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const WURZEL = join(HIER, ".."); +const ZIEL = join(WURZEL, "videos"); +const CREW = "crew.dogfather-universe.com"; + +const ordner = mkdtempSync(join(tmpdir(), "tk-video-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +/* Eine eigene Nummer. Frei sein muss sie trotzdem -- sonst filmt + dieses Werkzeug einen fremden Server, und man sieht es dem Bild + nicht an. */ +const PORT = 5471; +{ + const frei = await new Promise((fertig) => { + const s = netServer(); + s.once("error", () => fertig(false)); + s.once("listening", () => s.close(() => fertig(true))); + s.listen(PORT, "127.0.0.1"); + }); + if (!frei) { console.error(`Port ${PORT} ist belegt. Abgebrochen.`); process.exit(3); } +} + +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"); +await new Promise((r) => setTimeout(r, 1200)); + +/* ---------- Sprechen mit Hausadresse ---------------------------------- */ +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 = httpAnfrage({ + 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(); + }); +} + +/* ---------- Leute anlegen --------------------------------------------- + Erfundene Namen, erfundene Beiträge. Nichts davon stammt aus dem + echten Haus, und nichts davon geht dorthin zurück. */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const nun = new Date().toISOString(); +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, nun); +/* ==== DAS ANLEGEDATUM IST NICHT EGAL ============================ + + Der Treff hat drei Stufen, und sie werden aus dem Alter des + Zugangs gerechnet: „Neu" die erste Woche, „Dabei" ab sieben Tagen, + „Stamm" ab dreissig Tagen UND drei eigenen Beitraegen. Wer neu ist, + darf lesen und im Chat schreiben -- mehr nicht. + + Das ist eine gute Regel (die erste Woche ist eine Sandkiste), und + sie hat den ersten Fuellversuch vollstaendig abgewiesen: acht mal + 403, Bretter leer, und im Video haette eine leere App gestanden. + Die Statisten bekommen deshalb ein Datum von vor 200 Tagen. + ============================================================== */ +const VOR_200_TAGEN = new Date(Date.now() - 200 * 86400000).toISOString(); +function person(name, rolle, code, seit = nun) { + 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"), seit); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +const LEUTE = [ + ["DogFather", "admin"], ["VanVan", "hand"], ["Funny", "linke"], + ["Miss Kira", "modi"], ["Nachtfalter", "gast"], ["Lenny", "gast"], + ["Sanja", "gast"], ["Tobi", "gast"], ["Mo", "gast"], ["Elif", "gast"], +]; +const CODE = {}; const ID = {}; +LEUTE.forEach(([name, rolle], i) => { + CODE[name] = `CODE-TK${String(i).padStart(2, "0")}-0001`; + ID[name] = person(name, rolle, CODE[name], rolle === "gast" ? VOR_200_TAGEN : nun); +}); +d.close(); + +/* `alter_ok` ist Pflicht, und zwar nur fuer die Community: Wer von + aussen kommt, bestaetigt beim ersten Anmelden sein Alter (18+). + Ohne das Feld antwortet die Anmeldung mit 400 „alter_offen" -- der + erste Lauf hat deshalb die Zugangswand gefilmt statt der App, und + im Bild sah es aus wie ein Fehler an der Aufnahme. */ +const keks = {}; +for (const [name, rolle] of LEUTE) { + const a = await roh("/workspace/api/anmelden", { + method: "POST", rumpf: { rolle, code: CODE[name], alter_ok: true }, + }); + keks[name] = [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; "); + if (!keks[name]) { console.error(`Anmeldung fuer ${name} (${rolle}) ging nicht: ${a.code} ${a.text.slice(0, 120)}`); process.exit(4); } +} + +/* ---------- Die Bretter füllen ---------------------------------------- + Über die echten Wege, nicht über die Datenbank: Was hier entsteht, + hat dieselben Prüfungen durchlaufen wie ein echter Beitrag -- und + sieht deshalb auch genauso aus. */ +let schiefgegangen = 0; +async function eintrag(wer, brett, art, titel, text) { + const a = await roh(`/workspace/api/bereich/${brett}`, { + method: "POST", keks: keks[wer], rumpf: { art, titel, text }, + }); + const id = a.j?.eintrag?.id ?? a.j?.id; + /* LAUT SCHEITERN. Ein stiller Fehlschlag hier heisst: leeres Brett + im Video -- und das sieht aus wie eine leere App. */ + if (!id) { + schiefgegangen++; + console.error(` ${brett}/${art} von ${wer}: ${a.code} ${String(a.text).slice(0, 140)}`); + } + /* Freigeben, damit es die Community sieht -- auf den Treff-Brettern + entscheidet das Team, was steht. */ + if (id) { + await roh(`/workspace/api/treff/eintrag/${id}/freigeben`, + { method: "PUT", keks: keks.DogFather, rumpf: {} }); + } + return id; +} + +console.log("Fuelle die Bretter …"); + +/* Die Reihenfolge ist Absicht: „Stamm" verlangt drei eigene + Beitraege auf Treff-Brettern. Wer spaeter ein Highlight einstellen + soll, muss vorher drei Wuensche geschrieben haben -- sonst gilt + dieselbe Sperre wie beim ersten Versuch. */ +await eintrag("DogFather", "anschlag", "ansage", + "Freitag 20 Uhr — der grosse Stream", "Bringt Zeit mit. Es gibt was zu feiern."); +await eintrag("DogFather", "anschlag", "regel", + "Keine Handynummern im Chat", "Auch nicht privat. Wir passen hier aufeinander auf."); +await eintrag("VanVan", "anschlag", "danke", + "Danke an alle vom Samstag", "Ihr wart vierhundert. Vierhundert!"); + +await eintrag("DogFather", "ansteht", "stream", "Freitag 20:00 — Rudel-Abend", + "Der lange. Mit allem."); +await eintrag("DogFather", "ansteht", "event", "Sonntag 18:00 — Fragerunde", + "Ihr fragt, ich antworte. Ohne Filter."); +await eintrag("Miss Kira", "ansteht", "turnier", "Mittwoch — Turnier, 16 Plaetze", + "Anmelden im Chat. Wer zuerst kommt."); + +await eintrag("Mo", "wunsch", "stream", "Nochmal die Horror-Nacht", + "Die von letztem Jahr war das Beste. Bitte nochmal."); +await eintrag("Mo", "wunsch", "gemeinsam", "Gemeinsames Kochen im Live", + "Jeder kocht mit, am Ende zeigen alle den Teller."); +await eintrag("Mo", "wunsch", "technik", "Bitte eine zweite Kamera", + "Man sieht Casper nie. Casper ist wichtig."); +await eintrag("Nachtfalter", "wunsch", "stream", "Rueckblick auf das erste Jahr", + "Mit den alten Clips. Ich heul dann halt."); +await eintrag("Nachtfalter", "wunsch", "gemeinsam", "Spieleabend mit dem Rudel", + "Zehn Leute, ein Spiel, offene Kamera."); +await eintrag("Nachtfalter", "wunsch", "sonstiges", "Merch in Groesse XXL", + "Ich bin gross. Wir sind mehr, als ihr denkt."); +await eintrag("Sanja", "wunsch", "stream", "Ein Stream nur mit Musik", + "Einfach reden und Platten auflegen."); +await eintrag("Sanja", "wunsch", "technik", "Untertitel fuer die Aufzeichnungen", + "Meine Schwester hoert schlecht und schaut trotzdem jede Folge."); +await eintrag("Sanja", "wunsch", "gemeinsam", "Rudel-Treffen in echt", + "Einmal im Jahr. Irgendwo in der Mitte."); + +/* Jetzt reichen die drei fuer „Stamm" -- und duerfen Highlights. */ +await eintrag("Mo", "highlight", "clip", "Der Moment, als der Ton wegflog", + "Zwanzig Sekunden Stille und dann alle am Lachen."); +await eintrag("Nachtfalter", "highlight", "moment", "1000 Follower — live erlebt", + "Wir haben alle mitgezaehlt."); +await eintrag("Sanja", "highlight", "fanart", "Gezeichnet: Casper", + "Zwei Abende gebraucht. Er guckt genau so."); + +await eintrag("DogFather", "mitmachen", "gesucht", "Wir suchen zwei Modis", + "Du musst nichts koennen. Du musst da sein wollen."); +await eintrag("Mo", "mitmachen", "lust", "Ich haette Lust auf Moderation", + "Bin fast jeden Abend da. Traue mich langsam."); +await eintrag("Sanja", "mitmachen", "frage", "Wie viel Zeit kostet das?", + "Ich wuerde gern, hab aber Schichtdienst."); + +if (schiefgegangen) { + console.error(`${schiefgegangen} Beitraege sind nicht entstanden. Es wird nicht gefilmt.`); + process.exit(6); +} + +/* ---------- Der Chat ---------------------------------------------------- */ +/* DIESER AUFRUF LEGT DEN RAUM ERST AN. Der Treff-Chat entsteht beim + ersten Mal, wenn ein Community-Mitglied nach den Raeumen fragt -- + vorher gibt es ihn nicht. Erkannt wird er an der KATEGORIE und + nicht am Namen: Der Name („Das Rudel") ist schon einmal gewandert. */ +const raeume = await roh("/workspace/api/chat/raeume", { keks: keks.Nachtfalter }); +const treffRaum = (raeume.j?.raeume || []).find((r) => r?.kategorie === "treff")?.id; + +const CHAT = [ + ["Nachtfalter", "Guten Abend zusammen 🐺"], + ["Lenny", "Moin! Ist er heute pünktlich?"], + ["Sanja", "Er ist NIE pünktlich"], + ["Miss Kira", "Ich hab ihn gerade gehört, er kommt gleich"], + ["Mo", "Ich bring heute jemanden mit, sie schaut zum ersten Mal"], + ["Elif", "Willkommen an sie! Hier ist es nett, versprochen"], + ["Tobi", "Kann jemand den Link zur Fragerunde... ach nee, steht ja unter Was ansteht"], + ["Nachtfalter", "Genau deswegen liebe ich das hier"], +]; +if (treffRaum) { + for (const [wer, text] of CHAT) { + await roh(`/workspace/api/chat/raeume/${treffRaum}/nachrichten`, { + method: "POST", keks: keks[wer], rumpf: { text }, + }); + await new Promise((r) => setTimeout(r, 60)); + } +} +console.log(`Chat: ${treffRaum ? "gefuellt" : "Raum nicht gefunden"}`); + +/* ---------- NACHGESEHEN, NICHT GEHOFFT -------------------------------- + + Ein leeres Brett sieht im Film aus wie eine leere App. Der erste + Lauf hat 38 Sekunden lang die Zugangswand gefilmt, weil die + Anmeldung still gescheitert war -- gemerkt habe ich es erst am + fertigen Video. Deshalb wird hier abgebrochen statt gedreht. */ +{ + const probe = await roh("/workspace/api/bereich/wunsch", { keks: keks.Nachtfalter }); + const n = (probe.j?.eintraege || probe.j?.liste || []).length; + const ich = await roh("/workspace/api/ich", { keks: keks.Nachtfalter }); + console.log(`Gegenprobe: angemeldet als ${ich.j?.name || "?"} (${ich.j?.rolle || "?"}), ` + + `${n} Wuensche auf dem Brett, ${treffRaum ? CHAT.length : 0} Nachrichten im Chat`); + if (ich.code !== 200 || n < 3 || !treffRaum) { + console.error("Die Buehne steht nicht. Es wird nicht gefilmt."); + process.exit(5); + } +} + +/* ---------- Der Vorbau mit dem Zertifikat ------------------------------- */ +const key = join(ordner, "v.key"), crt = join(ordner, "v.crt"); +execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", + "-keyout", key, "-out", crt, "-days", "2", + "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`], { stdio: "ignore" }); +const HTTPS_PORT = PORT + 1; +const vorbau = httpsServer({ key: readFileSync(key), cert: readFileSync(crt) }, (von, nach) => { + const weiter = httpAnfrage({ + 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}`; + +/* ======================================================================= + DIE BÜHNE — Karten, Untertitel und ein Finger + + Alles davon wird IN DIE SEITE gezeichnet und nicht nachträglich von + ffmpeg darübergelegt. Zwei Gründe: Es benutzt dieselbe Schrift wie + die App (das Video sieht aus wie ein Stück), und Umlaute sind in + ffmpegs Textfilter auf Windows eine eigene Wissenschaft. + ======================================================================= */ +const BUEHNE = ` +(() => { + if (document.getElementById("tk-buehne")) return; + const s = document.createElement("style"); + s.textContent = \` + #tk-buehne{position:fixed;inset:0;z-index:2147483000;pointer-events:none; + font-family:system-ui,"Segoe UI",sans-serif} + #tk-karte{position:absolute;inset:0;display:grid;place-items:center; + background:#05070d;opacity:0;transition:opacity .28s ease;padding:0 34px} + #tk-karte.an{opacity:1} + #tk-karte p{margin:0;text-align:center;font-weight:800;font-size:27px; + line-height:1.24;color:#fff;letter-spacing:-.01em; + text-shadow:0 0 30px rgba(158,222,144,.28)} + #tk-karte p b{color:#9cde90} + #tk-satz{position:absolute;left:16px;right:16px;bottom:210px; + opacity:0;transform:translateY(8px);transition:opacity .22s ease,transform .22s ease} + #tk-satz.an{opacity:1;transform:none} + #tk-satz span{display:inline-block;background:rgba(5,7,13,.86); + border:1px solid rgba(158,222,144,.32);border-radius:12px; + padding:9px 13px;font-weight:700;font-size:18px;line-height:1.32;color:#fff; + box-shadow:0 10px 30px -12px #000} + #tk-finger{position:absolute;width:34px;height:34px;margin:-17px 0 0 -17px; + border-radius:50%;background:rgba(255,255,255,.22); + border:2px solid rgba(255,255,255,.75);opacity:0; + transition:left .45s cubic-bezier(.4,0,.2,1),top .45s cubic-bezier(.4,0,.2,1), + opacity .2s ease,transform .18s ease} + #tk-finger.an{opacity:1} + #tk-finger.tipp{transform:scale(.7);background:rgba(158,222,144,.55)} + \`; + document.head.appendChild(s); + const b = document.createElement("div"); + b.id = "tk-buehne"; + b.innerHTML = '