/* ===================================================================== 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=12 TREFF_NACHT_BIS=6 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 { istNachtruhe } = await import("./workspace-treffchat.js"); 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 große 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 Plätze", "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", "Rückblick 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 Größe XXL", "Ich bin groß. 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 für die Aufzeichnungen", "Meine Schwester hört schlecht und schaut trotzdem jede Folge."); await eintrag("Sanja", "wunsch", "gemeinsam", "Rudel-Treffen in echt", "Einmal im Jahr. Irgendwo in der Mitte."); /* ---------- „Will ich auch" --------------------------------------------- Video 4 sagt: „Und die anderen sehen, wer mitwill." Im ersten Lauf stand an jedem Wunsch „Will ich auch · 0" -- der Untertitel behauptete etwas, das auf dem Bildschirm widerlegt wurde. Das ist schlimmer als ein leeres Brett: Es ist ein Versprechen, das das Bild gleich wieder einkassiert. Gestimmt wird ueber die echte Route, mit echten Zugaengen. Wie viele mitwollen, ist je Wunsch verschieden -- alle gleich waere sichtbar gebaut. */ { const brett = await roh("/workspace/api/bereich/wunsch", { keks: keks.DogFather }); const liste = brett.j?.eintraege || brett.j?.liste || []; const WAEHLER = ["Nachtfalter", "Lenny", "Sanja", "Tobi", "Mo", "Elif", "Miss Kira"]; let stimmen = 0; for (const [i, e] of liste.entries()) { /* 6, 5, 4 … absteigend: Die Liste sortiert nach Zuspruch, und eine Reihenfolge, die man versteht, sieht echter aus als Zufall. */ const wie_viele = Math.max(0, 6 - i); for (const wer of WAEHLER.slice(0, wie_viele)) { const a = await roh(`/workspace/api/treff/eintrag/${e.id}/willich`, { method: "PUT", keks: keks[wer], rumpf: {} }); if (a.code === 200) stimmen++; } } console.log(`Will ich auch: ${stimmen} Stimmen auf ${liste.length} Wuensche`); if (stimmen < 10) { console.error("Zu wenige Stimmen -- der Untertitel wuerde luegen. Es wird nicht gefilmt."); process.exit(7); } } /* 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 mitgezählt."); 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 können. Du musst da sein wollen."); await eintrag("Mo", "mitmachen", "lust", "Ich hätte Lust auf Moderation", "Bin fast jeden Abend da. Traue mich langsam."); await eintrag("Sanja", "mitmachen", "frage", "Wie viel Zeit kostet das?", "Ich würde 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"], ]; let imChat = 0; let imRegal = 0; if (treffRaum) { /* GESCHRIEBEN WIRD AN DER SCHNITTSTELLE VORBEI -- UND ZWAR ABSICHTLICH (27.09.2026). Fuer Video 2 laeuft die Nachtruhe. Sie verbietet das Schreiben ALLEN, auch dieser Vorbereitung -- der erste Lauf hat deshalb einen leeren Chat gefilmt. Ein Sonderweg in der Nachtruhe waere genau das Loch, das der Film bewirbt; also wird hier nichts aufgeweicht, sondern die Buehne direkt aufgebaut. Dieselbe Sorte Eingriff wie das Anlegen der Statisten ein paar Zeilen weiter oben, und ebenfalls nur in der Wegwerf-Datenbank. Dass es angekommen ist, wird gleich danach an der Schnittstelle NACHGELESEN -- lesen darf man auch nachts. */ const db = new DatabaseSync(process.env.WORKSPACE_DB); const wannBasis = Date.now() - CHAT.length * 90_000; let letzte = null; CHAT.forEach(([wer, text], i) => { const wann = new Date(wannBasis + i * 90_000).toISOString(); db.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)") .run(treffRaum, ID[wer], text, wann); letzte = wann; }); db.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(letzte, treffRaum); db.close(); /* NACHGEZAEHLT STATT ANGENOMMEN (27.09.2026). Der erste Lauf von Video 2 hat einen LEEREN Chat gefilmt. Die Nachtruhe war fuer diesen Film eingeschaltet -- und sie verbietet nicht nur den Zuschauern das Schreiben, sondern auch dieser Vorbereitung. Geschrieben wurde nichts, gemeldet wurde „gefuellt", weil nur geprueft war, dass es den Raum GIBT. Gelesen werden darf immer; deshalb faellt es hier auf. */ const nach = await roh(`/workspace/api/chat/raeume/${treffRaum}/nachrichten`, { keks: keks.Nachtfalter }); imChat = (nach.j?.nachrichten || nach.j?.liste || []).length; } console.log(`Chat: ${treffRaum ? `${imChat} von ${CHAT.length} Nachrichten` : "Raum nicht gefunden"}`); /* ---------- Dogi-Media --------------------------------------------------- Video 3 handelt von Material zum Mitnehmen. Der erste Lauf filmte "Gerade ist nichts frei. Schau spaeter noch einmal rein." -- eine leere Seite, drei mal vier Sekunden lang. Das Brett war gefuellt, das Medienregal nicht, und niemand hatte danach gefragt. Eingestellt wird an der Schnittstelle vorbei, aus demselben Grund wie beim Chat: Das Hochladen erwartet einen Rumpf aus Bytes mit passendem Typ, und die Dateien liegen ohnehin schon im Haus. Die Namen sind echte Marken-Bilder -- kein Platzhalter, der im Video wie ein Platzhalter aussieht. */ { const { mkdirSync: mk, copyFileSync, statSync } = await import("node:fs"); const mOrdner = join(ordner, "material"); mk(mOrdner, { recursive: true }); const STUECKE = [ ["dogfather-husky.png", "Dogi-Kopf freigestellt", "Für Storys und Kommentare. Hintergrund frei."], ["hasidog-hase.png", "Hasi Dog freigestellt", "Passt zu allem, was mit dem Rudel zu tun hat."], ["lounge.png", "Lounge als Hintergrund", "Für eigene Clips, hochkant beschnitten."], ["logo.png", "Team-Dogi-Logo", "Wenn du das Rudel erwähnst."], ["gate-spicy-dogfather.jpeg", "Studio-Standbild", "Aus dem letzten Stream. Freigegeben zum Posten."], ["logo-kopf-frei.png", "Kopf klein für die Ecke", "Klein, transparent, für die Bildecke."], ]; /* DIE TABELLE GIBT ES ERST NACH DEM ERSTEN ZUGRIFF. Material legt sein Schema traege an -- ein direktes INSERT davor scheitert mit "no such table: material". Ein Abruf reicht, um sie zu bauen. */ await roh("/workspace/api/material", { keks: keks.DogFather }); const db2 = new DatabaseSync(process.env.WORKSPACE_DB); let n = 0; STUECKE.forEach(([quelle, name, text], i) => { const von = join(WURZEL, "assets", "img", "marke", quelle); const endung = quelle.slice(quelle.lastIndexOf(".")); const datei = `tk-${i}${endung}`; let bytes = 0; try { copyFileSync(von, join(mOrdner, datei)); bytes = statSync(join(mOrdner, datei)).size; } catch { return; } db2.prepare(`INSERT INTO material (datei, dateiname, typ, art, groesse, text, von_id, erstellt, haus) VALUES (?,?,?,?,?,?,?,?,?)`) .run(datei, name + endung, endung === ".jpeg" ? "image/jpeg" : "image/png", "bild", bytes, text, ID.DogFather, new Date(Date.now() - (6 - i) * 3600_000).toISOString(), "crew"); n++; }); /* Zwei sind schon weg -- das ist die Aussage des Videos, und sie muss auf dem Bildschirm zu sehen sein, nicht nur im Untertitel. */ const genommen = db2.prepare("SELECT id FROM material ORDER BY id LIMIT 2").all(); for (const g of genommen) { db2.prepare("UPDATE material SET genommen_von = ?, genommen_am = ? WHERE id = ?") .run(ID.Lenny, new Date(Date.now() - 7200_000).toISOString(), g.id); } db2.close(); imRegal = n; console.log(`Dogi-Media: ${n} Stuecke, davon 2 schon genommen`); } /* ---------- 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, ${imChat} Nachrichten im Chat, ` + `${imRegal} Stuecke in Dogi-Media`); if (ich.code !== 200 || n < 3 || !treffRaum || imChat < CHAT.length || imRegal < 4) { 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 = '