diff --git a/server/tiktok-videos.mjs b/server/tiktok-videos.mjs index 1c057485..1d6fbac8 100644 --- a/server/tiktok-videos.mjs +++ b/server/tiktok-videos.mjs @@ -516,48 +516,207 @@ const BASIS = `https://${CREW}:${HTTPS_PORT}`; die App (das Video sieht aus wie ein Stück), und Umlaute sind in ffmpegs Textfilter auf Windows eine eigene Wissenschaft. ======================================================================= */ -const BUEHNE = ` -(() => { - if (document.getElementById("tk-buehne")) return; - const s = document.createElement("style"); - s.textContent = \` - #tk-buehne{position:fixed;inset:0;z-index:2147483000;pointer-events:none; - font-family:system-ui,"Segoe UI",sans-serif} - #tk-karte{position:absolute;inset:0;display:grid;place-items:center; - background:#05070d;opacity:0;transition:opacity .28s ease;padding:0 34px} - #tk-karte.an{opacity:1} - #tk-karte p{margin:0;text-align:center;font-weight:800;font-size:27px; - line-height:1.24;color:#fff;letter-spacing:-.01em; - text-shadow:0 0 30px rgba(158,222,144,.28)} - #tk-karte p b{color:#9cde90} - #tk-satz{position:absolute;left:16px;right:16px;bottom:210px; - opacity:0;transform:translateY(8px);transition:opacity .22s ease,transform .22s ease} - #tk-satz.an{opacity:1;transform:none} - #tk-satz span{display:inline-block;background:rgba(5,7,13,.86); - border:1px solid rgba(158,222,144,.32);border-radius:12px; - padding:9px 13px;font-weight:700;font-size:18px;line-height:1.32;color:#fff; - box-shadow:0 10px 30px -12px #000} - #tk-finger{position:absolute;width:34px;height:34px;margin:-17px 0 0 -17px; - border-radius:50%;background:rgba(255,255,255,.22); - border:2px solid rgba(255,255,255,.75);opacity:0; - transition:left .45s cubic-bezier(.4,0,.2,1),top .45s cubic-bezier(.4,0,.2,1), - opacity .2s ease,transform .18s ease} - #tk-finger.an{opacity:1} - #tk-finger.tipp{transform:scale(.7);background:rgba(158,222,144,.55)} - \`; - document.head.appendChild(s); +/* ======================================================================= + 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 = [ + '#tk-buehne{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:' + VIOLETT + ';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,rgba(169,139,255,0),' + VIOLETT + ')}', + '#tk-karte .kicker i + i{background:linear-gradient(90deg,' + VIOLETT + ',rgba(169,139,255,0))}', + '#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:' + AKZENT + ';font-weight:800;', + ' text-shadow:0 1px 2px rgba(3,2,8,.85),0 0 16px rgba(142,201,255,.30)}', + + /* 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%)}', + '#tk-karte.ende p{font-size:32px}', + /* `display:block` ist nicht Zierde: Ein 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 rgba(169,139,255,.26);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,' + VIOLETT + ',' + AKZENT + ')}', + '#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,' + VIOLETT + ' 0%,#4b3fa8 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 = '

' - + '
'; + b.innerHTML = + '
' + + '
' + + 'Team Dogi' + + '

' + + '
' + + '
' + + '
' + + '
'; 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 @@ -608,17 +767,66 @@ ABBRUCH -- auf ${woher} steht etwas im Bild, was nicht ins Video darf:`); } function buehneFuer(seite) { - const auf = () => seite.evaluate(BUEHNE); + /* 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; + const auf = () => seite.evaluate(BUEHNE, vorhangZu); + + /* 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).catch(() => {}); }); + return { auf, - /** Eine Vollbild-Karte. `` hebt ein Wort hervor. */ - async karte(html, ms = 2000, { bleibt = false } = {}) { + + /** Vorhang zu. Danach darf navigiert werden. */ + async zu(ms = 380) { await auf(); - await seite.evaluate((t) => { + 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. `` 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); + }, [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 @@ -633,7 +841,7 @@ function buehneFuer(seite) { await auf(); await seite.evaluate((t) => { const s = document.getElementById("tk-satz"); - s.querySelector("span").textContent = t; + s.querySelector("em").textContent = t; s.classList.add("an"); }, text); await warte(ms); @@ -726,8 +934,8 @@ const FILME = { 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 Zuhause gebaut.", 2100); + await b.karte("Ein Kommentar ist nach drei Sekunden weg.", 2100); + await b.karte("Also hab ich dem Rudel ein Zuhause gebaut.", 2100); await seite.goto(`${BASIS}/workspace/start.html`, { waitUntil: "networkidle" }); await warte(800); await b.satz("Vierzehn Türen. Kein Discord.", 1900); @@ -738,8 +946,8 @@ const FILME = { 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 unsers.", 2200); - await b.karte(CTA, 2600, { bleibt: true }); + await b.karte("Das ist nicht mein Kanal. Das ist unsers.", 2200); + await b.karte(CTA, 2800, { bleibt: true, ende: true }); }, }, @@ -748,8 +956,8 @@ const FILME = { 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 schickt euch
ins Bett
.", 2000); + await b.karte("Jede App will, dass du um drei noch wach bist.", 2200); + await b.karte("Meine schickt euch ins Bett.", 2000); await seite.goto(`${BASIS}/workspace/chat.html?raum=treff`, { waitUntil: "networkidle" }); await warte(1600); await b.rollenIn(".chat__verlauf", -260, 1200); @@ -758,8 +966,8 @@ const FILME = { 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 Leben habt.", 2400); - await b.karte(CTA, 2600, { bleibt: true }); + await b.karte("Weil ihr morgen auch noch ein Leben habt.", 2400); + await b.karte(CTA, 2800, { bleibt: true, ende: true }); }, }, @@ -767,16 +975,16 @@ const FILME = { 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
nur eins.", 1900); + await b.karte("Ich hab Clips und Bilder für euch zum Posten.", 2100); + await b.karte("Aber jeder kriegt nur eins.", 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 alle was haben.", 2400); - await b.karte(CTA, 2600, { bleibt: true }); + await b.karte("Damit nicht einer alles nimmt. Damit alle was haben.", 2400); + await b.karte(CTA, 2800, { bleibt: true, ende: true }); }, }, @@ -785,7 +993,7 @@ const FILME = { 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 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); @@ -798,8 +1006,8 @@ const FILME = { 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 da sein wollen.", 2400); - await b.karte(CTA, 2600, { bleibt: true }); + await b.karte("Du musst nichts können. Du musst da sein wollen.", 2400); + await b.karte(CTA, 2800, { bleibt: true, ende: true }); }, }, }; @@ -853,20 +1061,54 @@ for (const nr of liste) { return { name: n, value: r.join("="), domain: CREW, path: "/" }; })); const seite = await kontext.newPage(); - await seite.setContent(''); + await seite.setContent(''); const b = buehneFuer(seite); await b.auf(); - /* Jeder Seitenwechsel wird abgesucht, bevor die Kamera ihn zeigt. */ + + /* ==== 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. */ + 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; }; - /* Die Bühne muss nach JEDEM Seitenwechsel neu hinein -- sie lebt im - Dokument, und das wird beim Laden ausgetauscht. */ - seite.on("load", () => { seite.evaluate(BUEHNE).catch(() => {}); }); await film.lauf(seite, b); await warte(400);