Filipe, 27.09.2026: „nimm dann doch die seiten vom community bereich
ne?"
Er hatte recht, und es war derselbe Fehler noch einmal. Beim ersten
Mal stand die Buehne in einer Farbe aus einem fremden Bereich
(#9cde90, die Notizen-Kachel aus dem Team). Beim zweiten Mal stand
sie in EINER Farbe fuer alles -- und jeder Bereich des Treffs hat
seine eigene:
Rudel-Chat #5499ff Wunschliste #abff96
Highlights #f60684 Dogi-Media #918a01
Anschlagbrett #ffabff Mitmachen #577ecc
Was ansteht #2dc9ba Unterstuetzen #e76006
Ein Video ueber Dogi-Media mit violetten Einblendungen sind zwei
Sachen nebeneinander. Mit der Farbe des Bereichs ist es eine.
ABGELESEN, NICHT ABGESCHRIEBEN
Die Buehne fragt nach jedem Seitenwechsel
`getComputedStyle(document.body).--ton` -- genau den Wert, den die
Seite selbst benutzt. Eine Liste hier waere eine zweite Wahrheit, die
irgendwann von der ersten abweicht; tools/treff-farben.mjs liest
dieselben zwei Quellen (workspace.js fuer die Nummer, start.css fuer
den Farbwert), wenn man sie nachsehen will.
AUFGEHELLT, BIS ES LESBAR IST
#918a01 ist als Flaeche schoen und als Schrift auf dunklem Grund
unlesbar (1,9:1). Die Buehne mischt Weiss dazu, bis 4,5:1 erreicht
sind -- dieselbe Rechnung wie bei den Namen im Chat. Balken, Schein
und Vorhang bekommen den rohen Ton, die brauchen keinen Kontrast.
DER ABSPANN BLEIBT MARKE
Er ist in allen vier Videos derselbe und faellt deshalb auf
--violett/--akzent zurueck, egal auf welcher Seite er steht. Wer drei
Videos gesehen hat, soll den vierten am Schluss wiedererkennen.
UND EIN EIGENER FEHLER, GEMESSEN STATT UEBERSEHEN
Das Ablesen stand zuerst in `auf()` -- und `auf()` laeuft vor jeder
Karte, jedem Untertitel und jedem Rollen. Ueber hundert zusaetzliche
Wege in den Browser fuer eine Antwort, die sich nur beim
Seitenwechsel aendern kann. Video 1 wuchs dadurch von 32,9 auf
36,1 s. Jetzt wird beim Seitenwechsel gelesen: 32,0 s.
Alle vier neu gedreht: 24 bis 34 s, 1080x1920.
1218 lines
56 KiB
JavaScript
1218 lines
56 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";
|
||
|
||
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");
|
||
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.
|
||
======================================================================= */
|
||
/* =======================================================================
|
||
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);
|