DER BEFUND
Im naechtlichen Lauf standen dreizehn Pruefungen mit derselben Zeile
rot:
[uncaughtException] Error: spawnSync openssl ENOENT
Kein Programmfehler. Die Pruefungen brauchen einen https-Vorbau (der
Sitzungskeks des Hauses ist `secure` und kommt ueber http nicht an),
und dafuer erzeugen sie ein Wegwerf-Zertifikat mit openssl.
GEMESSEN, NICHT VERMUTET
openssl liegt hier: C:\Program Files\Git\mingw64\bin\openssl.exe
C:\Program Files\Git\usr\bin\openssl.exe
im System-PATH steht: C:\Program Files\Git\cmd (nur git.exe)
im Benutzer-PATH: nichts mit Git
Wer aus Git Bash startet, erbt die mingw-Pfade und merkt nie etwas.
Die Aufgabenplanung startet `node.exe` DIREKT, ohne Shell -- und
bekommt sie nicht. Bei mir gruen, nachts rot, und der Grund steht
nicht im Code.
Das Schlimmste daran ist nicht der Ausfall: Dreizehn dauerhaft rote
Zeilen in einer Notiz, die Filipe morgens liest, gewoehnen einem das
Hinsehen ab -- und decken dabei die echten Befunde zu.
DIE REPARATUR
`server/helfer-openssl.mjs` sucht openssl erst im PATH (ohne `where`
oder `which` -- beides sind selbst Programme und koennen genauso
fehlen), danach an den Orten, an denen es auf einem Windows-Rechner
mit Git wirklich liegt.
Dazu `zertifikatBauen(schluessel, zertifikat, host)`. Die acht
Aufrufformen im Haus waren identisch bis auf die Variablennamen
(schl/zert, schluesselDatei/zertDatei, zKey/zCrt, sk/zt, key/crt) --
eine Stelle traegt alle neunundzwanzig. Zwei Tage zuvor war eine
davon um ein `-addext` aermer als die anderen; gemerkt hat es
niemand, weil der Browser den fehlenden Alternativnamen erst bei
einer Weiterleitung anmahnt. Eine Stelle kann nicht von sich selbst
abweichen.
KEIN EINTRAG IN DEN SYSTEM-PATH. `mingw64\bin` enthaelt rund hundert
Programme mit Unix-Namen (`find`, `sort`, `link`), die gleichnamige
Windows-Befehle verdecken. Das fuer eine Pruefung zu aendern haette
an ganz anderen Stellen Fehler verursacht, die niemand hierher
zurueckverfolgt.
GEGENPROBE IN BEIDE RICHTUNGEN, mit dem PATH des Nachtlaufs:
vorher (direkter Aufruf): ABSTURZ: spawnSync openssl ENOENT
nachher (ueber den Helfer): Zertifikat gebaut, 1236 Bytes
Und eine echte Pruefung unter denselben Bedingungen:
PATH ohne mingw64 -> pruef-chat-neu-stelle
63 Pruefungen, 0 Fehler, 0 Abstuerze
Genau diese Datei stand im Nachtlauf mit ENOENT rot.
EINE WACHE DAGEGEN
pruef-struktur prueft ab jetzt, dass niemand openssl wieder direkt
ruft -- der naechste merkt es dort und nicht erst in einem
Nachtlauf, den niemand liest. Drei Gegenproben.
Beim ersten Anlauf schlug sie auf ihre EIGENEN Probetexte an: Sie
liest alle Serverdateien, und dazu gehoert sie selbst. Die Texte
werden jetzt zusammengesetzt. Derselbe Selbsttreffer ist mir heute
schon zweimal passiert.
DER DRITTE AUSGANG BLEIBT, WO ER HINGEHOERT
`helfer-kachel-echtfarbe.mjs` faengt den Fehler weiterhin ab und
meldet `moeglich: false` mit Grund, statt abzubrechen. Eine
Sicherung abzuschaffen, weil ihr Anlass gerade behoben ist, ist der
Anfang des naechsten stillen Fehlschlags.
NEBENBEI: pruef-community-sicht
Sie war rot mit "ZU VIEL: reaktion.html" -- mein eigener Rueckstand
vom 28.09. Die Reaction steht als Kachel im Community-Bereich; die
Liste war nicht nachgezogen. Jetzt 10 / 0.
Die Liste bleibt bewusst von Hand gepflegt: Sie ist eine ABSICHT,
keine Ableitung. Aus rechte.js gelesen verglichen sich zwei Kopien
derselben Quelle -- immer gruen, nie ein Beweis.
GEPRUEFT
pruef-struktur ALLES IN ORDNUNG (mit der neuen Wache)
pruef-community-sicht 10 / 0
pruef-chat-neu-stelle 63 / 0 (mit dem PATH des Nachtlaufs)
pruef-notizen, -teamlage-karten, -werdegang: EXIT 0, keine Abstuerze
mess-quer EXIT 0
29 Dateien: node --check auf allen, kein direkter Aufruf mehr
1217 lines
55 KiB
JavaScript
1217 lines
55 KiB
JavaScript
/* =====================================================================
|
||
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";
|
||
import { zertifikatBauen } from "./helfer-openssl.mjs";
|
||
|
||
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");
|
||
|
||
/* ==== EIGENE ZUHOERER NEBEN DIE VON index.js ==========================
|
||
(27.09.2026, nachdem dieses Werkzeug zweimal zwoelf Minuten lang
|
||
an nichts gearbeitet hat.)
|
||
|
||
index.js spannt am Ende ein Auffangnetz: `uncaughtException` und
|
||
`unhandledRejection` werden nur PROTOKOLLIERT. Fuer den Betrieb ist
|
||
das genau richtig -- ein Fehler in einer Nebensache darf die
|
||
Website nicht offline nehmen.
|
||
|
||
Wer die Datei importiert, erbt das Netz. Beim ersten Versuch, das
|
||
Medienregal zu fuellen, warf das Werkzeug "no such table:
|
||
material". Der Fehler wurde aufgeschrieben, sonst nichts -- und
|
||
der laufende HTTP-Server hielt den Prozess danach am Leben. Von
|
||
aussen sah das aus wie "rechnet noch"; tatsaechlich war schon
|
||
alles vorbei. Genau diese Falle steht seit dem 06.09.2026 in den
|
||
Projektregeln, und ich bin trotzdem hineingelaufen.
|
||
|
||
`process.on` ERGAENZT, es ersetzt nicht: Die Meldung von index.js
|
||
kommt weiterhin, danach beendet dieser Zuhoerer den Lauf. Ein
|
||
Werkzeug, das abstuerzt, soll abstuerzen. */
|
||
for (const art of ["uncaughtException", "unhandledRejection"]) {
|
||
process.on(art, (f) => {
|
||
console.error(`
|
||
ABBRUCH (${art}): ${f?.stack || f}`);
|
||
process.exit(8);
|
||
});
|
||
}
|
||
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;
|
||
}
|
||
|
||
/* GEGENPROBE ZUM ABBRUCH (PROBE_ABSTURZ=ja): Wirft mitten im Lauf.
|
||
Ohne die Zuhoerer oben bliebe der Prozess danach stehen, bis
|
||
jemand ihn beendet -- mit ihnen endet er sofort und mit
|
||
Rueckgabewert 8. Ein Netz, das man nie hat reissen sehen, ist eine
|
||
Hoffnung. */
|
||
if (process.env.PROBE_ABSTURZ === "ja") {
|
||
setTimeout(() => { throw new Error("Probe: so sieht ein Absturz aus"); }, 50);
|
||
}
|
||
|
||
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");
|
||
zertifikatBauen(key, crt, CREW);
|
||
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.
|
||
======================================================================= */
|
||
/* =======================================================================
|
||
DIE BUEHNE — was ueber der App liegt
|
||
|
||
EINE FUNKTION STATT EINER ZEICHENKETTE: Vorher stand hier ein
|
||
Text, der im Browser ausgewertet wurde, und darin noch einmal ein
|
||
Text fuer das Stilblatt. Zwei Ebenen Anfuehrungszeichen, in denen
|
||
jedes Backtick und jedes ${ von Hand entschaerft werden musste --
|
||
dabei ist jede zweite Aenderung kaputtgegangen. Playwright nimmt
|
||
auch eine echte Funktion und reicht ein Argument durch; damit
|
||
faellt die ganze Schicht weg.
|
||
|
||
DIE FARBEN KOMMEN AUS DEM HAUS, DAS IM BILD IST. crew-haus.css
|
||
fuehrt --akzent #8ec9ff und --violett #a98bff auf --tinte #07060f.
|
||
Vorher stand hier #9cde90 -- der Ton der Notizen-Kachel aus dem
|
||
Teambereich, der in diesen Videos gar nicht vorkommt. Eine
|
||
Einblendung in einer Farbe, die im Bild fehlt, sieht aufgeklebt
|
||
aus, egal wie sauber sie gebaut ist.
|
||
|
||
GEMESSEN WIRD IN 360x640 UND GEZEIGT IN 1080x1920: Der Browser
|
||
laeuft mit dreifacher Geraeteskalierung. Jede Zahl hier ist also
|
||
ein Drittel dessen, was am Ende zu sehen ist -- 30px Schrift sind
|
||
90px im Video.
|
||
======================================================================= */
|
||
function BUEHNE(blendeZu) {
|
||
if (document.getElementById("tk-buehne")) {
|
||
/* Nach einem Seitenwechsel steht sie schon -- dann nur den
|
||
Vorhang in den richtigen Zustand bringen. */
|
||
const v = document.getElementById("tk-blende");
|
||
if (v) v.className = blendeZu ? "zu" : "";
|
||
return;
|
||
}
|
||
|
||
const AKZENT = "#8ec9ff";
|
||
const VIOLETT = "#a98bff";
|
||
const TINTE = "#07060f";
|
||
|
||
/* Feines Korn. Ohne das wirken grosse dunkle Flaechen im Video
|
||
flach und zeigen Streifen, weil der Kodierer wenig Information
|
||
hat, an der er sich festhalten kann. */
|
||
const KORN = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' "
|
||
+ "width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' "
|
||
+ "baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' "
|
||
+ "filter='url(%23n)'/%3E%3C/svg%3E\")";
|
||
|
||
const css = [
|
||
/* Zwei Regler: `--tk-ton` ist die rohe Bereichsfarbe (Balken,
|
||
Schein, Vorhang), `--tk-ton-text` dieselbe Farbe so weit
|
||
aufgehellt, dass man sie LESEN kann. Vorgabe sind die
|
||
Hausfarben -- solange keine Seite geladen ist, gibt es keinen
|
||
Bereich, dessen Farbe man nehmen koennte. */
|
||
'#tk-buehne{--tk-ton:' + VIOLETT + ';--tk-ton-text:' + VIOLETT + ';',
|
||
' --tk-hell:' + AKZENT + ';',
|
||
' position:fixed;inset:0;z-index:2147483000;pointer-events:none;',
|
||
' font-family:"Segoe UI",system-ui,-apple-system,"Helvetica Neue",Arial,sans-serif;',
|
||
' -webkit-font-smoothing:antialiased}',
|
||
|
||
/* ---- Der Schleier ueber der App ------------------------------
|
||
Nimmt den Raendern Helligkeit, damit Schrift darauf immer
|
||
liest -- und gibt dem Bild eine Mitte. Sehr leicht: Es soll
|
||
niemandem auffallen, es soll nur wirken. */
|
||
'#tk-vig{position:absolute;inset:0;background:',
|
||
' radial-gradient(125% 85% at 50% 42%, rgba(0,0,0,0) 52%, rgba(4,3,10,.52) 100%)}',
|
||
|
||
/* ---- Die Vollbild-Karte --------------------------------------- */
|
||
'#tk-karte{position:absolute;inset:0;display:grid;place-items:center;',
|
||
' padding:0 30px;opacity:0;transition:opacity .30s ease}',
|
||
'#tk-karte.an{opacity:1}',
|
||
'#tk-karte::before{content:"";position:absolute;inset:0;background:',
|
||
' radial-gradient(115% 62% at 16% 6%, rgba(169,139,255,.26), rgba(0,0,0,0) 62%),',
|
||
' radial-gradient(105% 58% at 88% 96%, rgba(142,201,255,.18), rgba(0,0,0,0) 64%),',
|
||
' linear-gradient(180deg,#0c0a1b 0%,' + TINTE + ' 58%,#050409 100%)}',
|
||
'#tk-karte::after{content:"";position:absolute;inset:0;opacity:.05;',
|
||
' background-image:' + KORN + ';background-size:140px 140px}',
|
||
|
||
/* Der Inhalt kommt langsam naeher. Eine Karte, die exakt
|
||
stillsteht, sieht aus wie ein Bild; drei Prozent Bewegung ueber
|
||
zweieinhalb Sekunden sieht aus wie Film. */
|
||
'#tk-karte .innen{position:relative;text-align:center;max-width:300px;',
|
||
' transform:scale(1.035);transition:transform 2.6s cubic-bezier(.16,.8,.3,1)}',
|
||
'#tk-karte.an .innen{transform:scale(1)}',
|
||
|
||
/* Die Zierzeile -- dieselbe Idee wie ueber dem Titel der App:
|
||
zwei Striche, ein Wort dazwischen, weit gesperrt. */
|
||
'#tk-karte .kicker{display:flex;align-items:center;justify-content:center;gap:9px;',
|
||
' font-size:9px;font-weight:700;letter-spacing:.38em;text-transform:uppercase;',
|
||
' color:var(--tk-ton-text);opacity:0;transform:translateY(10px);',
|
||
' transition:opacity .42s ease .04s,transform .42s cubic-bezier(.16,.8,.3,1) .04s}',
|
||
'#tk-karte .kicker i{display:block;width:22px;height:1px;',
|
||
' background:linear-gradient(90deg,transparent,var(--tk-ton-text))}',
|
||
'#tk-karte .kicker i + i{background:linear-gradient(90deg,var(--tk-ton-text),transparent)}',
|
||
'#tk-karte.an .kicker{opacity:.92;transform:none}',
|
||
|
||
/* `text-wrap: balance` verteilt die Woerter selbst auf die Zeilen.
|
||
Von Hand gesetzte Umbrueche koennen das nicht: Sie kennen die
|
||
Schriftgroesse nicht, mit der sie spaeter gelesen werden --
|
||
genau so entstand „... ist nach / nach / drei Sekunden weg." */
|
||
'#tk-karte p{margin:14px 0 0;font-size:31px;font-weight:800;line-height:1.10;',
|
||
' letter-spacing:-.024em;color:#f1edff;text-wrap:balance;',
|
||
' opacity:0;transform:translateY(14px);',
|
||
' transition:opacity .46s ease .16s,transform .46s cubic-bezier(.16,.8,.3,1) .16s;',
|
||
' text-shadow:0 2px 26px rgba(0,0,0,.6)}',
|
||
'#tk-karte.an p{opacity:1;transform:none}',
|
||
/* Wenig Schein, dafuer ein enger dunkler Schatten: Ein breiter
|
||
Schein frisst die Kanten der Buchstaben, und im Video wird
|
||
daraus Matsch. */
|
||
'#tk-karte p b{color:var(--tk-ton-text);font-weight:800;',
|
||
' text-shadow:0 1px 2px rgba(3,2,8,.85)}',
|
||
|
||
/* Der Abspann bekommt mehr Gewicht als die Zwischenkarten: Er ist
|
||
das Einzige, was der Mensch danach noch tun soll. */
|
||
'#tk-karte.ende::before{background:',
|
||
' radial-gradient(100% 52% at 50% 6%, rgba(169,139,255,.36), rgba(0,0,0,0) 66%),',
|
||
' radial-gradient(130% 62% at 50% 94%, rgba(142,201,255,.22), rgba(0,0,0,0) 64%),',
|
||
' linear-gradient(180deg,#120e27 0%,#0a0817 56%,#050409 100%)}',
|
||
/* Der Abspann ist in allen vier Videos derselbe und soll auch so
|
||
aussehen -- er faellt deshalb auf die Hausfarben zurueck, egal
|
||
auf welcher Seite er steht. */
|
||
'#tk-karte.ende{--tk-ton-text:' + AKZENT + '}',
|
||
'#tk-karte.ende .kicker{color:' + VIOLETT + '}',
|
||
'#tk-karte.ende p{font-size:32px}',
|
||
/* `display:block` ist nicht Zierde: Ein <span> ist inline, und
|
||
dort gelten Hoehe, Breite und margin:auto nicht. Der Strich war
|
||
gebaut und trotzdem nie im Bild. */
|
||
'#tk-karte .strich{display:block;height:2px;width:0;margin:22px auto 0;border-radius:2px;',
|
||
' background:linear-gradient(90deg,' + VIOLETT + ',' + AKZENT + ');',
|
||
' transition:width .7s cubic-bezier(.16,.8,.3,1) .42s;opacity:0}',
|
||
'#tk-karte.ende .strich{opacity:1}',
|
||
'#tk-karte.ende.an .strich{width:132px}',
|
||
|
||
/* ---- Der Untertitel -------------------------------------------
|
||
Rechts bleibt Platz: Dort liegt auf TikTok die Knopfreihe
|
||
(Herz, Kommentar, Teilen). Ein Untertitel, der bis an den
|
||
rechten Rand geht, liegt im fertigen Beitrag halb darunter.
|
||
Unten bleibt Platz fuer Beschreibung und Ton. */
|
||
'#tk-satz{position:absolute;left:18px;right:62px;bottom:176px;',
|
||
' opacity:0;transform:translateY(12px);',
|
||
' transition:opacity .26s ease,transform .26s cubic-bezier(.16,.8,.3,1)}',
|
||
'#tk-satz.an{opacity:1;transform:none}',
|
||
'#tk-satz span{display:flex;align-items:stretch;gap:11px;',
|
||
' background:rgba(10,8,22,.80);backdrop-filter:blur(14px) saturate(1.25);',
|
||
' -webkit-backdrop-filter:blur(14px) saturate(1.25);',
|
||
' border:1px solid color-mix(in srgb,var(--tk-ton) 34%,transparent);',
|
||
' border-radius:15px;padding:11px 14px;',
|
||
' box-shadow:0 20px 44px -20px #000,inset 0 1px 0 rgba(255,255,255,.07)}',
|
||
'#tk-satz i{flex:none;width:3px;border-radius:3px;',
|
||
' background:linear-gradient(180deg,var(--tk-ton),var(--tk-hell))}',
|
||
'#tk-satz em{font-style:normal;font-weight:700;font-size:19px;line-height:1.32;',
|
||
' letter-spacing:-.006em;color:#ece8fa}',
|
||
|
||
/* ---- Der Vorhang ----------------------------------------------
|
||
Deckt den Seitenwechsel zu. Ohne ihn stehen „Eintraege werden
|
||
geladen ...", leere Geruste und ein kurzes Aufblitzen im
|
||
fertigen Video -- das sieht nach Bildschirmaufnahme aus und
|
||
nicht nach Film. */
|
||
'#tk-blende{position:absolute;inset:0;transform:translateY(102%);',
|
||
' background:linear-gradient(180deg,var(--tk-ton) 0%,',
|
||
' color-mix(in srgb,var(--tk-ton) 45%,#0b0918) 34%,#0b0918 78%,' + TINTE + ' 100%)}',
|
||
'#tk-blende.zu{transform:translateY(0);',
|
||
' transition:transform .34s cubic-bezier(.66,0,.34,1)}',
|
||
'#tk-blende.auf{transform:translateY(-102%);',
|
||
' transition:transform .40s cubic-bezier(.66,0,.34,1)}',
|
||
|
||
/* ---- Der Finger ------------------------------------------------ */
|
||
'#tk-finger{position:absolute;width:34px;height:34px;margin:-17px 0 0 -17px;',
|
||
' border-radius:50%;background:rgba(142,201,255,.20);',
|
||
' border:2px solid rgba(236,232,250,.78);opacity:0;',
|
||
' box-shadow:0 0 22px rgba(142,201,255,.35);',
|
||
' 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(.68);background:rgba(169,139,255,.55)}',
|
||
].join("");
|
||
|
||
const st = document.createElement("style");
|
||
st.textContent = css;
|
||
document.head.appendChild(st);
|
||
|
||
const b = document.createElement("div");
|
||
b.id = "tk-buehne";
|
||
b.innerHTML =
|
||
'<div id="tk-vig"></div>'
|
||
+ '<div id="tk-karte"><div class="innen">'
|
||
+ '<span class="kicker"><i></i>Team Dogi<i></i></span>'
|
||
+ '<p></p><span class="strich"></span>'
|
||
+ '</div></div>'
|
||
+ '<div id="tk-satz"><span><i></i><em></em></span></div>'
|
||
+ '<div id="tk-finger"></div>'
|
||
+ '<div id="tk-blende" class="' + (blendeZu ? "zu" : "") + '"></div>';
|
||
document.body.appendChild(b);
|
||
}
|
||
|
||
const pw = await import(
|
||
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
|
||
|
||
const warte = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
/** Wartet, bis die Seite 650 ms am Stueck nicht mehr „laedt".
|
||
*
|
||
* Gibt zurueck, ob das gelungen ist -- der Aufrufer soll wissen, ob
|
||
* er gerade etwas filmt, das noch nicht fertig ist. */
|
||
async function ruhig(seite, frist = 9000) {
|
||
const bis = Date.now() + frist;
|
||
let seit = 0;
|
||
while (Date.now() < bis) {
|
||
const laedt = await seite.evaluate(
|
||
() => /wird geladen|werden geladen|einen moment/i.test(document.body?.innerText || ""),
|
||
).catch(() => false);
|
||
if (laedt) seit = 0;
|
||
else if (!seit) seit = Date.now();
|
||
if (seit && Date.now() - seit >= 650) return true;
|
||
await warte(110);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/* =======================================================================
|
||
DIE LECKSUCHE — kein Link darf ins Bild
|
||
|
||
Filipe, 27.09.2026: „ohne link bitte. es darf kein link zu sehen sein."
|
||
|
||
Das mit dem Auge zu pruefen waere genau die Sorte Kontrolle, die beim
|
||
vierten Video nachlaesst. Hier sieht stattdessen der Browser selbst
|
||
nach: Nach JEDEM Seitenwechsel wird der sichtbare Text abgesucht.
|
||
Findet sich eine Adresse, bricht die Aufnahme ab -- lieber kein Video
|
||
als eines, das die verborgene Adresse in die Welt traegt.
|
||
|
||
Gesucht wird im SICHTBAREN Text, nicht im Quelltext: Ein `href` steht
|
||
nirgends im Bild, ein Link als Beschriftung schon. Und `innerText`
|
||
liefert nur, was auch gezeichnet wird -- Verstecktes faellt von
|
||
selbst heraus.
|
||
======================================================================= */
|
||
const LECKS = [
|
||
[/dogfather-universe/i, "die Adresse des Hauses"],
|
||
[/https?:\/\//i, "eine vollstaendige Adresse"],
|
||
[/\bwww\./i, "ein www-Name"],
|
||
[/[a-z0-9-]+\.(com|de|net|org|app|dev)\b/i, "ein Domainname"],
|
||
];
|
||
|
||
async function lecksuche(seite, woher) {
|
||
/* GEGENPROBE (PROBE_LECK=ja): schiebt eine Adresse ins Bild. Eine
|
||
Suche, die nur „nichts gefunden" sagen kann, hat noch nichts
|
||
bewiesen -- damit laesst sich zeigen, dass sie auch anschlaegt. */
|
||
if (process.env.PROBE_LECK === "ja") {
|
||
await seite.evaluate(() => {
|
||
const p2 = document.createElement("p");
|
||
p2.textContent = "Probe: https://beispiel.example/seite";
|
||
document.body.prepend(p2);
|
||
});
|
||
}
|
||
const text = await seite.evaluate(() => document.body?.innerText || "");
|
||
const funde = [];
|
||
for (const [muster, was] of LECKS) {
|
||
const t = text.match(muster);
|
||
if (t) funde.push(`${was}: "${t[0]}"`);
|
||
}
|
||
if (funde.length) {
|
||
console.error(`
|
||
ABBRUCH -- auf ${woher} steht etwas im Bild, was nicht ins Video darf:`);
|
||
for (const f of funde) console.error(" " + f);
|
||
console.error("Kein Video wird geschrieben.");
|
||
process.exit(2);
|
||
}
|
||
}
|
||
|
||
function buehneFuer(seite) {
|
||
/* Ob der Vorhang zu ist, muss die Buehne beim Wiederaufbau wissen.
|
||
Ein Seitenwechsel wirft das ganze Dokument weg -- und wenn die
|
||
Buehne danach OHNE Vorhang zurueckkommt, sieht man genau das,
|
||
was sie zudecken sollte. Der Zustand lebt deshalb hier und nicht
|
||
im Browser. */
|
||
let vorhangZu = false;
|
||
|
||
/* Die Bereichsfarbe wird ABGELESEN, nicht gefuehrt: Jede Seite des
|
||
Treffs setzt `--ton` auf ihrem <body>, und genau dieser Wert geht
|
||
an die Buehne. Eine Liste hier waere eine zweite Wahrheit, die
|
||
irgendwann von der ersten abweicht. */
|
||
const TON_LESEN = () => {
|
||
const b = document.getElementById("tk-buehne");
|
||
if (!b) return null;
|
||
const roh = getComputedStyle(document.body).getPropertyValue("--ton").trim();
|
||
if (!/^#[0-9a-f]{6}$/i.test(roh)) return null;
|
||
|
||
/* Aufhellen, bis man es lesen kann -- dieselbe Rechnung wie bei
|
||
den Namen im Chat. #918a01 (Dogi-Media) kommt als Schrift auf
|
||
dunklem Grund sonst auf 1,9:1. */
|
||
const teile = (h) => [1, 3, 5].map((i) => parseInt(h.substr(i, 2), 16) / 255);
|
||
const gerade = (c) => (c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
|
||
const leucht = (v) => 0.2126 * gerade(v[0]) + 0.7152 * gerade(v[1]) + 0.0722 * gerade(v[2]);
|
||
const abstand = (x, y) => {
|
||
const a = leucht(x); const b2 = leucht(y);
|
||
const hoch = Math.max(a, b2); const tief = Math.min(a, b2);
|
||
return (hoch + 0.05) / (tief + 0.05);
|
||
};
|
||
const grund = [0.043, 0.035, 0.094]; /* #0b0918 */
|
||
const f = teile(roh);
|
||
let text = "#f1edff";
|
||
for (let t = 0; t <= 0.92; t += 0.04) {
|
||
const m = f.map((c) => c + (1 - c) * t);
|
||
if (abstand(m, grund) >= 4.5) {
|
||
text = "rgb(" + m.map((c) => Math.round(c * 255)).join(",") + ")";
|
||
break;
|
||
}
|
||
}
|
||
b.style.setProperty("--tk-ton", roh);
|
||
b.style.setProperty("--tk-ton-text", text);
|
||
b.style.setProperty("--tk-hell", text);
|
||
return roh;
|
||
};
|
||
|
||
/* DIE FARBE WIRD BEIM SEITENWECHSEL GELESEN, NICHT BEI JEDEM
|
||
HANDGRIFF (27.09.2026).
|
||
|
||
Erst stand das Ablesen in `auf()` -- und `auf()` laeuft vor jeder
|
||
Karte, jedem Untertitel und jedem Rollen. Das waren ueber
|
||
hundert zusaetzliche Wege in den Browser und drei Sekunden mehr
|
||
je Video, fuer eine Antwort, die sich nur beim Seitenwechsel
|
||
aendern kann. Gemessen: Video 1 wuchs von 32,9 auf 36,1 s. */
|
||
const auf = () => seite.evaluate(BUEHNE, vorhangZu);
|
||
const tonLesen = () => seite.evaluate(TON_LESEN).catch(() => null);
|
||
|
||
/* Der Wiederaufbau haengt hier und nicht beim Aufrufer: Er braucht
|
||
`vorhangZu`, und ein Zustand, den zwei Stellen fuehren, laeuft
|
||
auseinander. */
|
||
seite.on("load", () => {
|
||
seite.evaluate(BUEHNE, vorhangZu)
|
||
.then(() => seite.evaluate(TON_LESEN))
|
||
.catch(() => {});
|
||
});
|
||
|
||
return {
|
||
auf,
|
||
tonLesen,
|
||
|
||
/** Vorhang zu. Danach darf navigiert werden. */
|
||
async zu(ms = 380) {
|
||
await auf();
|
||
await seite.evaluate(() => {
|
||
const v = document.getElementById("tk-blende");
|
||
v.className = "";
|
||
void v.offsetWidth; /* Neuzeichnen erzwingen */
|
||
v.className = "zu";
|
||
});
|
||
vorhangZu = true;
|
||
await warte(ms);
|
||
},
|
||
|
||
/** Vorhang auf. */
|
||
async aufziehen(ms = 440) {
|
||
await auf();
|
||
await seite.evaluate(() => {
|
||
const v = document.getElementById("tk-blende");
|
||
v.className = "auf";
|
||
});
|
||
vorhangZu = false;
|
||
await warte(ms);
|
||
},
|
||
/** Eine Vollbild-Karte. `<b>` hebt ein Wort hervor.
|
||
*
|
||
* `ende: true` ist fuer den Abspann: andere Flaeche, groessere
|
||
* Schrift, ein Strich darunter. Er ist das Einzige, was der
|
||
* Mensch danach noch tun soll -- und soll sich deshalb von den
|
||
* Zwischenkarten unterscheiden. */
|
||
async karte(html, ms = 2000, { bleibt = false, ende = false } = {}) {
|
||
await auf();
|
||
await seite.evaluate(([t, istEnde]) => {
|
||
const k = document.getElementById("tk-karte");
|
||
k.querySelector("p").innerHTML = t;
|
||
k.classList.toggle("ende", !!istEnde);
|
||
/* Erst die Klasse weg, dann ein Neuzeichnen erzwingen, dann
|
||
wieder hin: Sonst laeuft die Bewegung bei der zweiten Karte
|
||
nicht noch einmal, weil sich am Zustand nichts geaendert
|
||
hat. Genau so sah die erste Karte lebendig aus und jede
|
||
weitere wie ein Standbild. */
|
||
k.classList.remove("an");
|
||
void k.offsetWidth;
|
||
k.classList.add("an");
|
||
}, [html, ende]);
|
||
await warte(ms);
|
||
/* `bleibt` ist fuer den Schluss. Ohne das nimmt die Karte sich
|
||
nach ihrer Zeit selbst weg -- und die letzte knappe Sekunde
|
||
des Videos zeigte wieder die App statt der Aufforderung.
|
||
Das Letzte, was haengenbleibt, soll der Satz sein. */
|
||
if (bleibt) return;
|
||
await seite.evaluate(() => document.getElementById("tk-karte").classList.remove("an"));
|
||
await warte(320);
|
||
},
|
||
/** Untertitel unten — über der TikTok-Bedienleiste. */
|
||
async satz(text, ms = 2200) {
|
||
await auf();
|
||
await seite.evaluate((t) => {
|
||
const s = document.getElementById("tk-satz");
|
||
s.querySelector("em").textContent = t;
|
||
s.classList.add("an");
|
||
}, text);
|
||
await warte(ms);
|
||
await seite.evaluate(() => document.getElementById("tk-satz").classList.remove("an"));
|
||
await warte(240);
|
||
},
|
||
async satzWeg() {
|
||
await seite.evaluate(() => document.getElementById("tk-satz")?.classList.remove("an"));
|
||
},
|
||
/** Finger hinbewegen, tippen, klicken. */
|
||
async tippen(wahl, { klick = true } = {}) {
|
||
await auf();
|
||
const ziel = seite.locator(wahl).first();
|
||
const kasten = await ziel.boundingBox().catch(() => null);
|
||
if (!kasten) return false;
|
||
const x = Math.round(kasten.x + kasten.width / 2);
|
||
const y = Math.round(kasten.y + Math.min(kasten.height / 2, 26));
|
||
await seite.evaluate(([a, b2]) => {
|
||
const f = document.getElementById("tk-finger");
|
||
f.style.left = a + "px"; f.style.top = b2 + "px";
|
||
f.classList.add("an");
|
||
}, [x, y]);
|
||
await warte(520);
|
||
await seite.evaluate(() => document.getElementById("tk-finger").classList.add("tipp"));
|
||
await warte(180);
|
||
if (klick) await ziel.click({ timeout: 8000 }).catch(() => {});
|
||
await seite.evaluate(() => {
|
||
const f = document.getElementById("tk-finger");
|
||
f.classList.remove("tipp"); f.classList.remove("an");
|
||
});
|
||
await warte(260);
|
||
return true;
|
||
},
|
||
/** Sanft scrollen — in kleinen Schritten, damit es fließt. */
|
||
/* Rollen INNERHALB eines Kastens.
|
||
|
||
`window.scrollBy` bewegt die Seite. Im Chat rollt aber nicht die
|
||
Seite, sondern der Verlauf in seinem eigenen Kasten -- der
|
||
Aufruf lief dort ins Leere, und drei Bilder hintereinander sahen
|
||
gleich aus. Gemerkt habe ich es am Kontaktbogen, nicht am
|
||
Quelltext. */
|
||
async rollenIn(wahl, px, ms = 1600) {
|
||
const schritte = Math.max(8, Math.round(ms / 40));
|
||
for (let i = 0; i < schritte; i++) {
|
||
await seite.evaluate(([w, p]) => {
|
||
document.querySelector(w)?.scrollBy(0, p);
|
||
}, [wahl, px / schritte]);
|
||
await warte(ms / schritte);
|
||
}
|
||
},
|
||
/* Sanft zu einem Element rollen -- und die Antwort messen, statt
|
||
eine Pixelzahl zu raten.
|
||
|
||
Video 4 hat beim ersten Lauf den KATALOG der fertigen
|
||
Vorschlaege gefilmt statt der Wuensche, weil „480 Pixel nach
|
||
unten" zufaellig dort endete. Wie hoch ein Block ist, haengt
|
||
davon ab, was darin steht -- eine feste Zahl ist eine Rechnung
|
||
von gestern. */
|
||
async zu(wahl, ms = 1400, rand = 150) {
|
||
const y = await seite.evaluate((w) => {
|
||
const e = document.querySelector(w);
|
||
if (!e) return null;
|
||
return e.getBoundingClientRect().top + window.scrollY;
|
||
}, wahl);
|
||
if (y === null) return false;
|
||
const von = await seite.evaluate(() => window.scrollY);
|
||
await this.rollen(Math.round(y - rand - von), ms);
|
||
return true;
|
||
},
|
||
async rollen(px, ms = 1600) {
|
||
const schritte = Math.max(8, Math.round(ms / 40));
|
||
for (let i = 0; i < schritte; i++) {
|
||
await seite.evaluate((p) => window.scrollBy(0, p), px / schritte);
|
||
await warte(ms / schritte);
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
/* =======================================================================
|
||
DIE VIER DREHBÜCHER
|
||
======================================================================= */
|
||
/* Der Schluss. KEIN LINK -- das war die Ansage. Ein Kommentarwort
|
||
ist ohnehin der bessere Weg: Es faellt dem Wecker von TikTok auf
|
||
(Kommentare zaehlen), und Filipe sieht, wer wirklich will. */
|
||
const CTA = "Zugang gibt es im Live.<br><b>Kommentier RUDEL.</b>";
|
||
|
||
const FILME = {
|
||
1: {
|
||
datei: "1-zuhause",
|
||
titel: "Das Zuhause",
|
||
async lauf(seite, b) {
|
||
await b.karte("Ein Kommentar ist nach drei Sekunden weg.", 2100);
|
||
await b.karte("Also hab ich dem Rudel ein <b>Zuhause</b> gebaut.", 2100);
|
||
await seite.goto(`${BASIS}/workspace/start.html`, { waitUntil: "networkidle" });
|
||
await warte(800);
|
||
await b.satz("Vierzehn Türen. Kein Discord.", 1900);
|
||
await b.rollen(560, 2100);
|
||
await b.satz("Chat. Anschlagbrett. Was ansteht.", 1900);
|
||
await b.rollen(600, 2100);
|
||
await b.satz("Eure Clips. Eure Wünsche.", 1900);
|
||
await b.rollen(620, 2100);
|
||
await b.satz("Und ein Weg, wenn was nicht stimmt.", 2000);
|
||
await b.rollen(420, 1500);
|
||
await b.karte("Das ist nicht mein Kanal. Das ist <b>unsers</b>.", 2200);
|
||
await b.karte(CTA, 2800, { bleibt: true, ende: true });
|
||
},
|
||
},
|
||
|
||
2: {
|
||
datei: "2-nachtruhe",
|
||
titel: "Der Chat, der schlaeft",
|
||
nachtruhe: true,
|
||
async lauf(seite, b) {
|
||
await b.karte("Jede App will, dass du um drei noch wach bist.", 2200);
|
||
await b.karte("Meine <b>schickt euch ins Bett</b>.", 2000);
|
||
await seite.goto(`${BASIS}/workspace/chat.html?raum=treff`, { waitUntil: "networkidle" });
|
||
await warte(1600);
|
||
await b.rollenIn(".chat__verlauf", -260, 1200);
|
||
await b.satz("Null bis sechs schläft der Rudel-Chat.", 2300);
|
||
await b.rollenIn(".chat__verlauf", 200, 1300);
|
||
await b.satz("Für alle. Auch für mich.", 2100);
|
||
await warte(500);
|
||
await b.satz("Lesen geht. Schreiben nicht.", 2100);
|
||
await b.karte("Weil ihr morgen auch noch ein <b>Leben</b> habt.", 2400);
|
||
await b.karte(CTA, 2800, { bleibt: true, ende: true });
|
||
},
|
||
},
|
||
|
||
3: {
|
||
datei: "3-jedes-nur-einmal",
|
||
titel: "Jedes nur einmal",
|
||
async lauf(seite, b) {
|
||
await b.karte("Ich hab Clips und Bilder für euch zum Posten.", 2100);
|
||
await b.karte("Aber jeder kriegt <b>nur eins</b>.", 1900);
|
||
await seite.goto(`${BASIS}/workspace/material.html`, { waitUntil: "networkidle" });
|
||
await warte(1100);
|
||
await b.satz("Dogi-Media. Fertig zum Hochladen.", 2000);
|
||
await b.rollen(500, 2200);
|
||
await b.satz("Wer eins genommen hat, sieht es nicht mehr.", 2300);
|
||
await b.rollen(420, 1700);
|
||
await b.karte("Damit nicht einer alles nimmt. Damit <b>alle</b> was haben.", 2400);
|
||
await b.karte(CTA, 2800, { bleibt: true, ende: true });
|
||
},
|
||
},
|
||
|
||
4: {
|
||
datei: "4-dazugehoeren",
|
||
titel: "Dazugehoeren",
|
||
async lauf(seite, b) {
|
||
await b.karte("Du wünschst dir was im Live.", 2000);
|
||
await b.karte("Und bis morgen hab ich es vergessen.", 2000);
|
||
await seite.goto(`${BASIS}/workspace/bereich.html?b=wunsch`, { waitUntil: "networkidle" });
|
||
await warte(1100);
|
||
await b.zu(".eintrag-karte", 1700);
|
||
await b.satz("Jetzt steht es da. Und geht nicht unter.", 2200);
|
||
await b.rollen(360, 1800);
|
||
await b.satz("Und die anderen sehen, wer mitwill.", 2100);
|
||
await b.rollen(340, 1500);
|
||
await seite.goto(`${BASIS}/workspace/bereich.html?b=mitmachen`, { waitUntil: "networkidle" });
|
||
await warte(1000);
|
||
await b.zu(".eintrag-karte", 1500);
|
||
await b.satz("Wer mehr will als zuschauen:", 2000);
|
||
await b.rollen(300, 1400);
|
||
await b.karte("Du musst nichts können. Du musst <b>da sein wollen</b>.", 2400);
|
||
await b.karte(CTA, 2800, { bleibt: true, ende: true });
|
||
},
|
||
},
|
||
};
|
||
|
||
/* =======================================================================
|
||
FILMEN
|
||
======================================================================= */
|
||
const gewuenscht = process.argv.slice(2).filter((a) => /^[1-4]$/.test(a)).map(Number);
|
||
const liste = gewuenscht.length ? gewuenscht : [1, 2, 3, 4];
|
||
|
||
mkdirSync(ZIEL, { recursive: true });
|
||
const rohOrdner = join(ordner, "roh");
|
||
mkdirSync(rohOrdner, { recursive: true });
|
||
|
||
const browser = await pw.chromium.launch({
|
||
args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors",
|
||
"--force-device-scale-factor=3", "--window-size=360,640", "--hide-scrollbars"],
|
||
});
|
||
|
||
const fertig = [];
|
||
for (const nr of liste) {
|
||
const film = FILME[nr];
|
||
/* GEFRAGT WIRD, OB DER CHAT SCHLAEFT -- NICHT NACH ZWEI ZAHLEN
|
||
(27.09.2026).
|
||
|
||
Hier stand ein Vergleich auf `TREFF_NACHT_AB === "0" &&
|
||
TREFF_NACHT_BIS === "24"`. Das ist die Abschrift einer
|
||
Einstellung und keine Frage nach dem Zustand: Jedes andere
|
||
Fenster, das den Chat genauso schlafen legt, wurde abgewiesen.
|
||
`istNachtruhe()` ist dieselbe Funktion, die auch die Seite
|
||
befragt -- damit kann die Antwort hier nicht von dem abweichen,
|
||
was im Bild steht.
|
||
|
||
Empfohlen wird 12 bis 6: Dann sagt das Band auf der Seite "Ab
|
||
06:00 Uhr geht es weiter" -- also genau das, was ein Mensch um
|
||
drei Uhr nachts auch lesen wuerde. Mit 0 bis 24 stand dort
|
||
"24:00", und das ergibt fuer einen Zuschauer keinen Sinn. */
|
||
if (film.nachtruhe && !istNachtruhe()) {
|
||
console.log(`\n--- Video ${nr} (${film.titel}) uebersprungen.`);
|
||
console.log(" Dafuer muss der Chat schlafen. Noch einmal starten mit:");
|
||
console.log(` TREFF_NACHT_AB=12 TREFF_NACHT_BIS=6 node server/tiktok-videos.mjs ${nr}`);
|
||
continue;
|
||
}
|
||
console.log(`\n--- Video ${nr}: ${film.titel}`);
|
||
const kontext = await browser.newContext({
|
||
viewport: null, ignoreHTTPSErrors: true,
|
||
recordVideo: { dir: rohOrdner, size: { width: 1080, height: 1920 } },
|
||
});
|
||
await kontext.addCookies(keks.Nachtfalter.split("; ").filter(Boolean).map((k) => {
|
||
const [n, ...r] = k.split("=");
|
||
return { name: n, value: r.join("="), domain: CREW, path: "/" };
|
||
}));
|
||
const seite = await kontext.newPage();
|
||
await seite.setContent('<body style="margin:0;background:#07060f"></body>');
|
||
const b = buehneFuer(seite);
|
||
await b.auf();
|
||
|
||
/* ==== JEDER SEITENWECHSEL LAEUFT HINTER DEM VORHANG ===============
|
||
(27.09.2026, nachdem Filipe die Videos gesehen hat.)
|
||
|
||
Vorher war jeder Wechsel im Bild: „Eintraege werden geladen ...",
|
||
leere Geruste, ein halb aufgebautes Raster, manchmal ein kurzes
|
||
Aufblitzen. Zusammen sah das nach Bildschirmaufnahme aus und
|
||
nicht nach Film -- und genau das war der Unterschied zwischen
|
||
„geht schon" und „sieht gut aus".
|
||
|
||
Der Ablauf ist immer derselbe und steht deshalb HIER und nicht in
|
||
den vier Drehbuechern: zu, laden, warten bis die Seite wirklich
|
||
steht, auf. Wer einen Schritt vergisst, sieht es erst im
|
||
fertigen Video.
|
||
|
||
ABGESUCHT WIRD, WAEHREND DER VORHANG NOCH ZU IST: Faende die
|
||
Lecksuche etwas, waere es nie im Bild gewesen. */
|
||
const gotoEcht = seite.goto.bind(seite);
|
||
seite.goto = async (...a) => {
|
||
await b.zu();
|
||
const r = await gotoEcht(...a);
|
||
await b.auf();
|
||
await lecksuche(seite, String(a[0]).replace(/^https?:\/\/[^/]+/, ""));
|
||
/* GEWARTET WIRD AUF RUHE, NICHT AUF DIE UHR UND NICHT AUF EINE
|
||
EINZELNE ANTWORT.
|
||
|
||
Erst standen hier 700 ms -- eine Zahl. Dann eine einmalige
|
||
Frage „steht irgendwo, dass geladen wird?" -- und die traf den
|
||
Moment, BEVOR die Seite ihren Ladehinweis gezeichnet hatte.
|
||
Im Video stand danach trotzdem „Eintraege werden geladen ...".
|
||
|
||
`ruhig()` verlangt 650 ms am Stueck ohne Hinweis. Eine Seite
|
||
laedt ihre Abschnitte nacheinander, und zwischen zweien ist
|
||
es kurz still -- 450 ms reichten nicht, der Vorhang ging in
|
||
dieser Stille auf. Kommt der Hinweis
|
||
doch noch, faengt die Uhr von vorn an. */
|
||
await b.tonLesen();
|
||
const still = await ruhig(seite);
|
||
if (!still) {
|
||
console.warn(` Hinweis: ${String(a[0]).replace(/^https?:\/\/[^/]+/, "")} `
|
||
+ "laedt laenger als acht Sekunden -- der Vorhang geht trotzdem auf.");
|
||
}
|
||
await warte(300);
|
||
await b.aufziehen();
|
||
return r;
|
||
};
|
||
|
||
await film.lauf(seite, b);
|
||
await warte(400);
|
||
const video = seite.video();
|
||
await kontext.close();
|
||
const quelle = await video.path();
|
||
const mp4 = join(ZIEL, `${film.datei}.mp4`);
|
||
|
||
/* ---------- Schneiden -------------------------------------------------
|
||
yuv420p und die geraden Kantenlängen sind nicht Geschmack: Ohne sie
|
||
zeigen manche Telefone ein grünes oder halbes Bild. 30 Bilder je
|
||
Sekunde, weil TikTok darunter ruckelt. Ein kurzes Auf- und
|
||
Abblenden nimmt dem Anfang die Härte. */
|
||
const dauer = Number(spawnSync("ffprobe", ["-v", "error", "-show_entries",
|
||
"format=duration", "-of", "default=nw=1:nk=1", quelle],
|
||
{ encoding: "utf8" }).stdout || 0);
|
||
const aus = spawnSync("ffmpeg", ["-y", "-v", "error", "-i", quelle,
|
||
"-vf", `fade=t=in:st=0:d=0.35,fade=t=out:st=${Math.max(0, dauer - 0.5).toFixed(2)}:d=0.5,`
|
||
+ "scale=1080:1920:flags=lanczos,format=yuv420p",
|
||
"-r", "30", "-c:v", "libx264", "-preset", "slow", "-crf", "19",
|
||
"-movflags", "+faststart", "-an", mp4], { encoding: "utf8" });
|
||
if (aus.status !== 0) {
|
||
console.error("ffmpeg:", (aus.stderr || "").slice(0, 400));
|
||
continue;
|
||
}
|
||
fertig.push({ nr, titel: film.titel, datei: mp4, sekunden: dauer });
|
||
console.log(` fertig: videos/${film.datei}.mp4 (${dauer.toFixed(1)} s)`);
|
||
}
|
||
|
||
await browser.close();
|
||
await new Promise((r) => vorbau.close(r));
|
||
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
|
||
|
||
console.log("\n=========================================");
|
||
for (const f of fertig) {
|
||
console.log(` ${f.nr}. ${f.titel.padEnd(24)} ${f.sekunden.toFixed(1)} s`);
|
||
}
|
||
console.log(`\n Ordner: ${ZIEL}`);
|
||
process.exit(0);
|